diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4589f46abd..774f0c1ebc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -138,6 +138,16 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
cache-prefix: test-${{ matrix.shard }}
+ # The @bb/host-workspace jj suites skip when the binary is absent, so
+ # without this step they would silently never run in CI.
+ - name: Install jj (Jujutsu) for colocated-repo tests
+ if: matrix.shard == 'packages'
+ run: |
+ mkdir -p "$HOME/.local/bin"
+ curl -fsSL https://github.com/jj-vcs/jj/releases/download/v0.44.0/jj-v0.44.0-x86_64-unknown-linux-musl.tar.gz \
+ | tar -xz -C "$HOME/.local/bin" ./jj
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+
- name: Test
run: pnpm exec turbo run test ${{ matrix.filter }} --cache-dir=.turbo/cache --output-logs=new-only ${{ matrix.args }}
diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts
index 68258ebc56..9622622d20 100644
--- a/apps/app/.ladle/story-fixtures.ts
+++ b/apps/app/.ladle/story-fixtures.ts
@@ -391,6 +391,7 @@ export function makeThreadListEntry(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
};
return { ...base, ...overrides };
@@ -466,6 +467,7 @@ export function makeEnvironment(
managed: true,
isGitRepo: true,
isWorktree: true,
+ vcs: null,
workspaceProvisionType: "managed-worktree",
branchName: BRANCH_NAMES.feature,
baseBranch: BRANCH_NAMES.default,
diff --git a/apps/app/src/components/commands/ThreadPaletteResults.test.tsx b/apps/app/src/components/commands/ThreadPaletteResults.test.tsx
index 5781c2cdb6..8ec6603e9e 100644
--- a/apps/app/src/components/commands/ThreadPaletteResults.test.tsx
+++ b/apps/app/src/components/commands/ThreadPaletteResults.test.tsx
@@ -57,6 +57,7 @@ function createThreadListEntry({
environmentId: null,
environmentName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
hasPendingInteraction: false,
id,
lastReadAt: null,
diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx
index f5c2823297..464fa618e5 100644
--- a/apps/app/src/components/pickers/EnvironmentPicker.tsx
+++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx
@@ -1,7 +1,10 @@
import { useMemo } from "react";
-import type { Host, ProjectSource } from "@bb/domain";
+import type { Host, ProjectSource, WorkspaceVcs } from "@bb/domain";
import { Icon, type IconName } from "@bb/shared-ui/icon";
-import { findLocalPathProjectSourceForHost } from "@bb/domain";
+import {
+ findLocalPathProjectSourceForHost,
+ managedCheckoutNoun,
+} from "@bb/domain";
import { Button } from "@bb/shared-ui/button";
import {
DropdownMenu,
@@ -78,6 +81,11 @@ export interface EnvironmentPickerUIProps {
reuseDisabled?: boolean;
/** Reason to disable "New worktree" while leaving local/remote work usable. */
worktreeDisabledReason?: string | null;
+ /**
+ * Which tool owns the project source's checkout. jj sources get workspaces
+ * rather than worktrees, and the picker names them that way.
+ */
+ vcs?: WorkspaceVcs | null;
/** Render with the dim, hover-to-foreground treatment used inside the prompt box. */
muted?: boolean;
/** Render as a non-interactive label while preserving the selected mode. */
@@ -104,6 +112,7 @@ export function EnvironmentPickerUI({
isLocal,
reuseDisabled,
worktreeDisabledReason,
+ vcs,
muted,
disabled = false,
className,
@@ -112,6 +121,11 @@ export function EnvironmentPickerUI({
machines,
onRequestMachineSetup,
}: EnvironmentPickerUIProps) {
+ const checkoutNoun = managedCheckoutNoun(vcs);
+ const checkoutNounCapitalized = managedCheckoutNoun(vcs, {
+ capitalized: true,
+ });
+ const checkoutNounPlural = managedCheckoutNoun(vcs, { plural: true });
const hostId = host?.id ?? null;
const isMachineMenu = (machines?.hosts.length ?? 0) > 1;
const hostConnected = host?.status === "connected";
@@ -139,7 +153,7 @@ export function EnvironmentPickerUI({
const newWorktreeDisabledReason =
workspaceDisabledReason ?? worktreeDisabledReason ?? null;
const reuseDisabledReason = reuseDisabled
- ? "No worktrees in this project yet"
+ ? `No ${checkoutNounPlural} in this project yet`
: null;
const parsed = useMemo(() => parseEnvironmentValue(value), [value]);
@@ -178,14 +192,19 @@ export function EnvironmentPickerUI({
}
if (parsed.type === "reuse") {
return {
- modeLabel: "Reuse worktree",
+ modeLabel: `Reuse ${checkoutNoun}`,
compactModeLabel: "Reuse",
icon: getEnvironmentWorkspaceLabelIconName("managed-worktree"),
};
}
- const modeLabel = parsed.mode === "worktree" ? "New worktree" : localLabel;
+ const modeLabel =
+ parsed.mode === "worktree" ? `New ${checkoutNoun}` : localLabel;
const compactModeLabel =
- parsed.mode === "worktree" ? "Worktree" : isLocal ? "Local" : "Remote";
+ parsed.mode === "worktree"
+ ? checkoutNounCapitalized
+ : isLocal
+ ? "Local"
+ : "Remote";
const icon = getEnvironmentWorkspaceLabelIconName(
parsed.mode === "worktree" ? "managed-worktree" : "other",
);
@@ -197,6 +216,8 @@ export function EnvironmentPickerUI({
icon,
};
}, [
+ checkoutNoun,
+ checkoutNounCapitalized,
parsed,
localLabel,
isLocal,
@@ -262,6 +283,7 @@ export function EnvironmentPickerUI({
sources={sources}
selectedHostId={parsed?.type === "host" ? parsed.hostId : hostId}
worktreeDisabledReason={worktreeDisabledReason ?? null}
+ vcs={vcs}
reuseDisabledReason={reuseDisabledReason}
selectedType={parsed?.type}
value={value}
@@ -276,6 +298,7 @@ export function EnvironmentPickerUI({
localLabel={localLabel}
workspaceDisabledReason={workspaceDisabledReason}
worktreeDisabledReason={newWorktreeDisabledReason}
+ vcs={vcs}
reuseDisabledReason={reuseDisabledReason}
selectedType={parsed?.type}
value={value}
@@ -300,6 +323,8 @@ interface EnvironmentOptionsSectionProps {
workspaceDisabledReason: string | null;
/** Why the worktree option is unavailable, or null when usable. */
worktreeDisabledReason: string | null;
+ /** Which tool owns the source checkout, so rows name it correctly. */
+ vcs: WorkspaceVcs | null | undefined;
/** Why the reuse option is unavailable, or null when usable. */
reuseDisabledReason: string | null;
selectedType:
@@ -316,11 +341,13 @@ function EnvironmentOptionsSection({
localLabel,
workspaceDisabledReason,
worktreeDisabledReason,
+ vcs,
reuseDisabledReason,
selectedType,
value,
onChange,
}: EnvironmentOptionsSectionProps) {
+ const checkoutNoun = managedCheckoutNoun(vcs);
const localValue = hostId ? encodeHostValue(hostId, "local") : null;
const worktreeValue = hostId ? encodeHostValue(hostId, "worktree") : null;
const workspaceDisabled = workspaceDisabledReason !== null;
@@ -355,7 +382,7 @@ function EnvironmentOptionsSection({
}}
/>
>["type"]
@@ -406,6 +435,7 @@ function MachineGroupedEnvironmentOptions({
sources,
selectedHostId,
worktreeDisabledReason,
+ vcs,
reuseDisabledReason,
selectedType,
value,
@@ -432,6 +462,7 @@ function MachineGroupedEnvironmentOptions({
worktreeDisabledReason={
machineHost.id === selectedHostId ? worktreeDisabledReason : null
}
+ vcs={machineHost.id === selectedHostId ? vcs : null}
now={now}
value={value}
onChange={onChange}
@@ -441,7 +472,7 @@ function MachineGroupedEnvironmentOptions({
void;
@@ -471,6 +504,7 @@ function MachineSection({
isThisMachine,
source,
worktreeDisabledReason,
+ vcs,
now,
value,
onChange,
@@ -511,7 +545,7 @@ function MachineSection({
onSelect={() => onChange(localValue)}
/>
@@ -149,14 +153,16 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
- Create new thread in this worktree
+
+ {`Create new thread in this ${checkoutNoun}`}
+
) : null}
diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx
index 566ec1aba6..dbc69c9dbe 100644
--- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx
@@ -144,6 +144,7 @@ export function Environment() {
@@ -156,6 +157,7 @@ export function Environment() {
isWorktree: false,
workspaceProvisionType: "unmanaged",
})}
+ environmentCheckout={null}
environmentDisplayHost={localEnvironmentDisplayHost}
/>
@@ -168,6 +170,7 @@ export function Environment() {
isWorktree: false,
workspaceProvisionType: "unmanaged",
})}
+ environmentCheckout={null}
environmentDisplayHost={remoteEnvironmentDisplayHost}
/>
@@ -181,6 +184,7 @@ export function Environment() {
isWorktree: false,
workspaceProvisionType: "managed-worktree",
})}
+ environmentCheckout={null}
environmentDisplayHost={localEnvironmentDisplayHost}
/>
@@ -287,6 +291,40 @@ export function Branch() {
/>
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx
index 4d5ad9d900..34ee54a310 100644
--- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx
@@ -47,6 +47,7 @@ function makeEnvironment(overrides: Partial = {}): Environment {
managed: true,
isGitRepo: true,
isWorktree: true,
+ vcs: null,
workspaceProvisionType: "managed-worktree",
branchName: "feature",
baseBranch: "main",
@@ -66,6 +67,7 @@ function renderEnvironmentRow(environment: Environment): string {
@@ -89,6 +91,7 @@ describe("EnvironmentRow", () => {
diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx
index b099b6bac6..91711149f6 100644
--- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx
@@ -4,6 +4,7 @@ import type { ThreadStorageBrowserController } from "./useThreadStorageBrowser";
import { Link } from "react-router-dom";
import type {
Environment,
+ GitCheckoutRef,
GitBranchRefClassification,
Thread,
ThreadListEntry,
@@ -75,6 +76,7 @@ import {
} from "@/components/pull-request/PullRequestStatusPill";
import { GithubFaviconIcon } from "@/components/pull-request/GithubFaviconIcon";
import { useUrlAnchorClickHandler } from "@/lib/url-open-routing";
+import { managedCheckoutNoun, resolveWorkspaceVcs } from "@bb/domain";
// ---------------------------------------------------------------------------
// Each row of the Info tab is a function component that owns its own raw
@@ -293,12 +295,18 @@ function ForksRow({ thread, projectId }: ForksRowProps) {
interface EnvironmentRowProps {
thread: Thread;
environment: Environment | null;
+ /**
+ * Live checkout, when known. Only used to recognize a jj workspace whose
+ * environment row predates bb recording which tool owns it.
+ */
+ environmentCheckout: GitCheckoutRef | null;
environmentDisplayHost: EnvironmentDisplayHostContext;
}
export function EnvironmentRow({
thread,
environment,
+ environmentCheckout,
environmentDisplayHost,
}: EnvironmentRowProps) {
const createThreadInWorktree = useCreateThreadInWorktree({
@@ -308,8 +316,15 @@ export function EnvironmentRow({
if (!environment) return null;
const display = formatEnvironmentDisplay({
environment,
+ checkout: environmentCheckout,
host: environmentDisplayHost,
});
+ const checkoutNoun = managedCheckoutNoun(
+ resolveWorkspaceVcs({
+ vcs: environment.vcs,
+ checkout: environmentCheckout,
+ }),
+ );
const showCreateThreadButton = isProvisionedWorktreeEnvironment(environment);
return (
- Create new thread in this worktree
+
+ {`Create new thread in this ${checkoutNoun}`}
+
) : null}
@@ -1046,6 +1063,7 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) {
diff --git a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts
index 03b284ee56..c6ceed5258 100644
--- a/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts
+++ b/apps/app/src/components/secondary-panel/git-diff/useEnvironmentMergeBase.test.ts
@@ -20,6 +20,7 @@ function makeEnvironment(overrides: EnvironmentOverrides = {}): Environment {
name: null,
isGitRepo: true,
isWorktree: true,
+ vcs: null,
managed: true,
mergeBaseBranch: null,
path: "/tmp/workspace",
diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx
index 4df2cd8de5..21d77ddba3 100644
--- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx
+++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx
@@ -61,6 +61,7 @@ function makeEnvironment(id: string, mergeBaseBranch: string): Environment {
name: null,
isGitRepo: true,
isWorktree: true,
+ vcs: null,
managed: true,
mergeBaseBranch,
path: `/tmp/${id}`,
diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
index e034107b7d..5e95900541 100644
--- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx
@@ -141,6 +141,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "active",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
index 09e37a21c0..7bdfac5bce 100644
--- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
@@ -124,6 +124,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
...overrides,
};
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx
index c59648f214..141e34a4b6 100644
--- a/apps/app/src/components/sidebar/ProjectRow.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.tsx
@@ -14,7 +14,11 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { createPortal } from "react-dom";
-import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain";
+import {
+ managedCheckoutNoun,
+ PERSONAL_PROJECT_ID,
+ type ThreadListEntry,
+} from "@bb/domain";
import type { ProjectResponse } from "@bb/server-contract";
import { NavLink } from "react-router-dom";
import { useCreateThreadInWorktree } from "@/hooks/useCreateThreadInWorktree";
@@ -407,6 +411,7 @@ interface EnvironmentThreadGroupHeaderProps {
}
interface EnvironmentThreadGroupHeaderActionsProps {
+ checkoutNoun: string;
archiveThreadsPending: boolean;
onArchiveThreads: () => void;
onCreateNewThread: () => void;
@@ -852,6 +857,7 @@ function useEnvironmentThreadGroupRenameAction({
}
function EnvironmentThreadGroupHeaderActions({
+ checkoutNoun,
archiveThreadsPending,
onArchiveThreads,
onCreateNewThread,
@@ -866,7 +872,7 @@ function EnvironmentThreadGroupHeaderActions({
type="button"
variant="ghost"
size="icon"
- aria-label="Worktree actions"
+ aria-label={`${checkoutNoun} actions`}
className={cn(
"rounded-md p-0 text-muted-foreground",
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-foreground",
@@ -903,7 +909,7 @@ function EnvironmentThreadGroupHeaderActions({
}}
>
- Archive worktree
+ {`Archive ${checkoutNoun.toLowerCase()}`}
@@ -928,7 +934,11 @@ function EnvironmentThreadGroupHeader({
const [isActionsOpen, setIsActionsOpen] = useState(false);
const environmentName = representativeThread.environmentName;
const branchName = representativeThread.environmentBranchName;
- const displayName = environmentName || branchName || "Worktree";
+ const checkoutNoun = managedCheckoutNoun(
+ representativeThread.environmentVcs,
+ { capitalized: true },
+ );
+ const displayName = environmentName || branchName || checkoutNoun;
const iconName: IconName = "FolderGit";
// Collapsed: the header speaks for its hidden children through one status
// glyph. Expanded: the children show their own glyphs, and the synthetic
@@ -1008,6 +1018,7 @@ function EnvironmentThreadGroupHeader({
)}
>
): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx b/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx
index 9d63097fac..c4cfec1040 100644
--- a/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx
+++ b/apps/app/src/components/sidebar/useSectionThreadDnd.projection.test.tsx
@@ -55,6 +55,7 @@ function createThread(overrides: Partial): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts
index 2c61826056..616f047482 100644
--- a/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts
+++ b/apps/app/src/components/sidebar/useSectionThreadDnd.test.ts
@@ -48,6 +48,7 @@ function createThread(overrides: Partial): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
index 236beb09f8..e3f9edb3f3 100644
--- a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx
@@ -53,6 +53,7 @@ function threadListEntry(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
index d0a532513b..0f7d7e464c 100644
--- a/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
+++ b/apps/app/src/components/thread/toc/ThreadTableOfContents.test.tsx
@@ -265,6 +265,7 @@ function threadListEntry(
environmentName: "ToC environment",
environmentBranchName: "main",
environmentWorkspaceDisplayKind: "managed-worktree",
+ environmentVcs: null,
...thread,
};
}
diff --git a/apps/app/src/hooks/cache-owners/environment-workspace-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/environment-workspace-cache-owner.test.ts
index ce4720939c..a1dd0d2717 100644
--- a/apps/app/src/hooks/cache-owners/environment-workspace-cache-owner.test.ts
+++ b/apps/app/src/hooks/cache-owners/environment-workspace-cache-owner.test.ts
@@ -14,6 +14,7 @@ function createEnvironment(): Environment {
id: "env_1",
isGitRepo: true,
isWorktree: true,
+ vcs: null,
managed: true,
mergeBaseBranch: null,
name: "Renamed environment",
diff --git a/apps/app/src/hooks/cache-owners/query-cache.ts b/apps/app/src/hooks/cache-owners/query-cache.ts
index 2d52b23375..7f5802f8b9 100644
--- a/apps/app/src/hooks/cache-owners/query-cache.ts
+++ b/apps/app/src/hooks/cache-owners/query-cache.ts
@@ -629,6 +629,7 @@ export function optimisticallyInsertThread(
hasPendingInteraction: false,
pinSortKey: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
},
...data,
]);
diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
index 4be042c9d3..185f5e02d6 100644
--- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
+++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
@@ -95,6 +95,7 @@ function makeThreadListEntry(id = "thread-1"): ThreadListEntry {
environmentName: "Environment",
environmentBranchName: "main",
environmentWorkspaceDisplayKind: "managed-worktree",
+ environmentVcs: null,
};
}
diff --git a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
index 24f344218b..46df7e0bfb 100644
--- a/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
+++ b/apps/app/src/hooks/cache-owners/thread-state-cache-owner.test.ts
@@ -63,6 +63,7 @@ function makeThreadListEntry(
environmentName: "Environment",
environmentBranchName: "main",
environmentWorkspaceDisplayKind: "managed-worktree",
+ environmentVcs: null,
...thread,
};
}
diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
index f4f5a866c8..abffdcaa0d 100644
--- a/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
+++ b/apps/app/src/hooks/mutations/thread-state-mutations.test.tsx
@@ -84,6 +84,7 @@ function makeThreadListEntry(
environmentName: "Environment",
environmentBranchName: "main",
environmentWorkspaceDisplayKind: "managed-worktree",
+ environmentVcs: null,
...thread,
};
}
diff --git a/apps/app/src/lib/plugin-sidebar-threads.test.ts b/apps/app/src/lib/plugin-sidebar-threads.test.ts
index f990941568..b25ebbb3bc 100644
--- a/apps/app/src/lib/plugin-sidebar-threads.test.ts
+++ b/apps/app/src/lib/plugin-sidebar-threads.test.ts
@@ -37,6 +37,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
...overrides,
};
@@ -152,6 +153,7 @@ describe("toPluginSidebarThread", () => {
environmentName: "Worktree",
environmentBranchName: "bb/feature",
environmentWorkspaceDisplayKind: "managed-worktree",
+ environmentVcs: null,
}),
);
expect(mapped.isPinned).toBe(true);
diff --git a/apps/app/src/lib/workspace-checkout-display.test.ts b/apps/app/src/lib/workspace-checkout-display.test.ts
index 1c4ed11d6f..b874b7c719 100644
--- a/apps/app/src/lib/workspace-checkout-display.test.ts
+++ b/apps/app/src/lib/workspace-checkout-display.test.ts
@@ -51,6 +51,40 @@ describe("formatWorkspaceCheckoutDisplay", () => {
});
});
+ it("formats a jj checkout with a bookmark as a copyable bookmark label", () => {
+ expect(
+ formatWorkspaceCheckoutDisplay({
+ checkout: {
+ kind: "detached",
+ headSha: "abcdef1234567890",
+ jj: { bookmark: "feature" },
+ },
+ }),
+ ).toMatchObject({
+ copyValue: "feature",
+ label: "feature",
+ rowLabel: "Bookmark",
+ title: "Copy bookmark name: feature",
+ });
+ });
+
+ it("formats a jj checkout without a bookmark as a short SHA label", () => {
+ expect(
+ formatWorkspaceCheckoutDisplay({
+ checkout: {
+ kind: "detached",
+ headSha: "abcdef1234567890",
+ jj: { bookmark: null },
+ },
+ }),
+ ).toMatchObject({
+ copyValue: "abcdef1234567890",
+ label: "jj abcdef1",
+ rowLabel: "Bookmark",
+ title: "jj checkout without a bookmark: abcdef1234567890",
+ });
+ });
+
it("formats an unborn checkout with a branch name", () => {
expect(
formatWorkspaceCheckoutDisplay({
diff --git a/apps/app/src/lib/workspace-checkout-display.ts b/apps/app/src/lib/workspace-checkout-display.ts
index bbf6c4a74e..c338ffde3e 100644
--- a/apps/app/src/lib/workspace-checkout-display.ts
+++ b/apps/app/src/lib/workspace-checkout-display.ts
@@ -8,7 +8,7 @@ export interface WorkspaceCheckoutDisplay {
copySuccessMessage: string | null;
copyValue: string | null;
label: string;
- rowLabel: "Branch" | "Checkout";
+ rowLabel: "Branch" | "Bookmark" | "Checkout";
title: string;
}
@@ -46,6 +46,28 @@ export function formatWorkspaceCheckoutDisplay({
title: "Detached HEAD",
};
}
+ if (checkout.jj) {
+ if (checkout.jj.bookmark !== null) {
+ return {
+ copyErrorMessage: "Failed to copy bookmark name",
+ copyLabel: "Copy bookmark name",
+ copySuccessMessage: "Bookmark name copied",
+ copyValue: checkout.jj.bookmark,
+ label: checkout.jj.bookmark,
+ rowLabel: "Bookmark",
+ title: `Copy bookmark name: ${checkout.jj.bookmark}`,
+ };
+ }
+ return {
+ copyErrorMessage: "Failed to copy commit SHA",
+ copyLabel: "Copy commit SHA",
+ copySuccessMessage: "Commit SHA copied",
+ copyValue: checkout.headSha,
+ label: `jj ${shortSha(checkout.headSha)}`,
+ rowLabel: "Bookmark",
+ title: `jj checkout without a bookmark: ${checkout.headSha}`,
+ };
+ }
return {
copyErrorMessage: "Failed to copy commit SHA",
copyLabel: "Copy commit SHA",
diff --git a/apps/app/src/test/fixtures/thread-list-entries.ts b/apps/app/src/test/fixtures/thread-list-entries.ts
index ca2faf5d26..0215c4ef3c 100644
--- a/apps/app/src/test/fixtures/thread-list-entries.ts
+++ b/apps/app/src/test/fixtures/thread-list-entries.ts
@@ -37,6 +37,7 @@ export function makeThreadListEntry(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
};
return { ...base, ...overrides };
diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx
index 69c8ff9b11..3ed94f1fc8 100644
--- a/apps/app/src/views/RootComposeMobileRecents.test.tsx
+++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx
@@ -41,6 +41,7 @@ function makeThread(overrides: Partial = {}): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "active",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts
index 9e76e33706..d9d4a79e88 100644
--- a/apps/app/src/views/RootComposeView.test.ts
+++ b/apps/app/src/views/RootComposeView.test.ts
@@ -311,6 +311,7 @@ function makeThread(args: MakeThreadArgs): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/apps/app/src/views/root-compose-branch-ui.test.ts b/apps/app/src/views/root-compose-branch-ui.test.ts
index d592dc5c77..74dc96125b 100644
--- a/apps/app/src/views/root-compose-branch-ui.test.ts
+++ b/apps/app/src/views/root-compose-branch-ui.test.ts
@@ -123,6 +123,58 @@ describe("buildRootComposeBranchUiState", () => {
});
});
+ it("labels a jj checkout with its bookmark and blocks branch mutation", () => {
+ expect(
+ buildRootComposeBranchUiState({
+ checkout: {
+ ...detachedCheckout,
+ checkout: {
+ kind: "detached",
+ headSha: "def987654321",
+ jj: { bookmark: "feature" },
+ },
+ },
+ isFetching: false,
+ isLoading: false,
+ mode: "local",
+ selectedBranch: null,
+ }),
+ ).toMatchObject({
+ currentBranch: null,
+ currentOptionLabel: "Current: feature (jj)",
+ triggerLabel: "Current (feature)",
+ mutationBlocker: {
+ label: "jj",
+ title: "Branch switching is managed by jj in this workspace",
+ },
+ });
+ });
+
+ it("labels a jj checkout without a bookmark", () => {
+ expect(
+ buildRootComposeBranchUiState({
+ checkout: {
+ ...detachedCheckout,
+ checkout: {
+ kind: "detached",
+ headSha: "def987654321",
+ jj: { bookmark: null },
+ },
+ },
+ isFetching: false,
+ isLoading: false,
+ mode: "local",
+ selectedBranch: null,
+ }),
+ ).toMatchObject({
+ currentOptionLabel: "Current (jj)",
+ triggerLabel: "Current (jj)",
+ mutationBlocker: {
+ label: "jj",
+ },
+ });
+ });
+
it("labels unborn branches as the current empty checkout", () => {
expect(
buildRootComposeBranchUiState({
diff --git a/apps/app/src/views/root-compose-branch-ui.ts b/apps/app/src/views/root-compose-branch-ui.ts
index e68eb114e5..f21f5e4790 100644
--- a/apps/app/src/views/root-compose-branch-ui.ts
+++ b/apps/app/src/views/root-compose-branch-ui.ts
@@ -62,6 +62,11 @@ function formatCurrentCheckoutLabel(
case "branch":
return `Current: ${checkout.branchName}`;
case "detached":
+ if (checkout.jj) {
+ return checkout.jj.bookmark !== null
+ ? `Current: ${checkout.jj.bookmark} (jj)`
+ : "Current (jj)";
+ }
return "Current (detached)";
case "unborn":
return "Current (empty repo)";
@@ -79,6 +84,11 @@ function formatCurrentCheckoutTriggerLabel(
case "branch":
return `Current (${checkout.branchName})`;
case "detached":
+ if (checkout.jj) {
+ return checkout.jj.bookmark !== null
+ ? `Current (${checkout.jj.bookmark})`
+ : "Current (jj)";
+ }
return "Current (detached)";
case "unborn":
return "Current (empty repo)";
@@ -146,6 +156,12 @@ export function resolveBranchMutationBlocker(
case "branch":
return null;
case "detached":
+ if (args.checkout.checkout.jj) {
+ return {
+ label: "jj",
+ title: "Branch switching is managed by jj in this workspace",
+ };
+ }
return {
label: "Detached",
title: "Checkout blocked while HEAD is detached",
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index c83769350d..738996172e 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -2415,6 +2415,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
const threadEnvironmentDisplay = environment
? formatEnvironmentDisplay({
environment,
+ checkout: workspaceStatus?.checkout,
host: environmentDisplayHostContext,
})
: undefined;
diff --git a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts
index e0c77887a0..cf5472c709 100644
--- a/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts
+++ b/apps/app/src/views/thread-detail/threadParentSelectorOptions.test.ts
@@ -24,6 +24,7 @@ function makeThread(overrides: ThreadListEntryOverrides = {}): ThreadListEntry {
environmentId: null,
environmentName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
hasPendingInteraction: false,
id: "thr_1",
lastReadAt: null,
diff --git a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts
index cbde587f9b..8ef1d4d9f0 100644
--- a/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts
+++ b/apps/app/src/views/thread-detail/threadWorkspaceOpenPath.test.ts
@@ -22,6 +22,7 @@ function makeEnvironment(overrides: Partial = {}): Environment {
name: null,
isGitRepo: true,
isWorktree: true,
+ vcs: null,
managed: true,
mergeBaseBranch: "main",
path: "/tmp/workspace",
diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
index 301fb76a77..9968a440c3 100644
--- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
+++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
@@ -143,6 +143,7 @@ export function makeEnvironment(overrides: MakeEnvironmentArgs): Environment {
path: "/tmp/environment",
managed: false,
isGitRepo: true,
+ vcs: null,
isWorktree: false,
workspaceProvisionType: "unmanaged",
branchName: "bb/thread",
diff --git a/apps/cli/src/commands/environment.ts b/apps/cli/src/commands/environment.ts
index 17d949c945..ad2ccb9552 100644
--- a/apps/cli/src/commands/environment.ts
+++ b/apps/cli/src/commands/environment.ts
@@ -11,6 +11,7 @@ import type {
} from "@bb/sdk";
import { action } from "../action.js";
import { createCliBbSdk } from "../client.js";
+import { managedCheckoutNoun } from "@bb/domain";
import {
outputJson,
prependErrorContext,
@@ -335,7 +336,9 @@ export function registerEnvironmentCommands(
console.log(` Merge base: ${env.mergeBaseBranch}`);
}
console.log(` Git repo: ${env.isGitRepo}`);
- console.log(` Worktree: ${env.isWorktree}`);
+ console.log(
+ ` ${managedCheckoutNoun(env.vcs, { capitalized: true })}: ${env.isWorktree}`,
+ );
console.log(` Created: ${new Date(env.createdAt).toLocaleString()}`);
console.log(` Updated: ${new Date(env.updatedAt).toLocaleString()}`);
}),
@@ -365,7 +368,14 @@ export function registerEnvironmentCommands(
}
const status = result.workspace;
console.log(`State: ${status.workingTree.state}`);
- console.log(`Branch: ${status.branch.currentBranch ?? "(detached)"}`);
+ const jjCheckout =
+ status.checkout.kind === "detached" ? status.checkout.jj : undefined;
+ const fallbackBranchLabel = jjCheckout
+ ? (jjCheckout.bookmark ?? "(jj, no bookmark)")
+ : "(detached)";
+ console.log(
+ `Branch: ${status.branch.currentBranch ?? fallbackBranchLabel}`,
+ );
console.log(`Default branch: ${status.branch.defaultBranch}`);
console.log(`Changed files: ${status.workingTree.files.length}`);
if (status.workingTree.lineStatsComplete) {
diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts
index f35110decb..300fa575df 100644
--- a/apps/host-daemon/src/command-dispatch.test.ts
+++ b/apps/host-daemon/src/command-dispatch.test.ts
@@ -133,6 +133,7 @@ function createWorkspace(workspacePath = WORKSPACE_PATH): HostWorkspace {
managed: false,
isGitRepo: false,
isWorktree: false,
+ vcs: "git" as const,
getDefaultBranch: unexpectedWorkspaceCall,
getCurrentBranch: unexpectedWorkspaceCall,
getHeadSha: unexpectedWorkspaceCall,
diff --git a/apps/host-daemon/src/command-handlers/environment.ts b/apps/host-daemon/src/command-handlers/environment.ts
index 2a4d55298a..38ae1c488f 100644
--- a/apps/host-daemon/src/command-handlers/environment.ts
+++ b/apps/host-daemon/src/command-handlers/environment.ts
@@ -98,6 +98,7 @@ export async function provisionEnvironment(
path: entry.workspace.path,
isGitRepo: entry.workspace.isGitRepo,
isWorktree: entry.workspace.isWorktree,
+ vcs: entry.workspace.vcs,
branchName,
defaultBranch,
transcript: alreadyExists ? [] : transcript,
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 8fef1f9d01..d3cb754169 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -170,6 +170,7 @@ function createFakeWorkspace(
managed: options.managed ?? false,
isGitRepo,
isWorktree: false,
+ vcs: "git" as const,
getDefaultBranch: vi.fn(async () => "main"),
getCurrentBranch: vi.fn(async (..._args: GetCurrentBranchArgs) => "main"),
getHeadSha: vi.fn(async () => "commit-1"),
diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts
index 31c48d0a8c..e78b7e7844 100644
--- a/apps/host-daemon/src/terminals/terminal-manager.test.ts
+++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts
@@ -239,6 +239,7 @@ function createFakeWorkspace(path: string): HostWorkspace {
managed: false,
isGitRepo: true,
isWorktree: false,
+ vcs: "git" as const,
getCurrentBranch: vi.fn(async () => "main"),
getHeadSha: vi.fn(async () => "commit-1"),
getLocalStateFingerprint: vi.fn(async () => "local-1"),
diff --git a/apps/host-daemon/src/watch-manager.test.ts b/apps/host-daemon/src/watch-manager.test.ts
index b06cf26146..84624c4575 100644
--- a/apps/host-daemon/src/watch-manager.test.ts
+++ b/apps/host-daemon/src/watch-manager.test.ts
@@ -35,6 +35,7 @@ function createFakeWorkspace(path: string, isGitRepo = true) {
managed: false,
isGitRepo,
isWorktree: false,
+ vcs: "git" as const,
getDefaultBranch: vi.fn(async () => "main"),
getCurrentBranch: vi.fn(async () => "main"),
getHeadSha: vi.fn(async () => "commit-1"),
diff --git a/apps/host-daemon/src/watch-manager.ts b/apps/host-daemon/src/watch-manager.ts
index 7da65ec508..0515728368 100644
--- a/apps/host-daemon/src/watch-manager.ts
+++ b/apps/host-daemon/src/watch-manager.ts
@@ -480,6 +480,7 @@ export class WatchManager {
path: workspace.path,
isGitRepo: true,
isWorktree: workspace.isWorktree,
+ vcs: workspace.vcs,
branchName,
defaultBranch: resolvedDefaultBranch ?? branchName,
},
diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts
index 035ec359f1..3833779a23 100644
--- a/apps/host-daemon/test/command/dispatch-helpers.ts
+++ b/apps/host-daemon/test/command/dispatch-helpers.ts
@@ -171,6 +171,7 @@ export function createFakeWorkspace(pathname: string) {
managed: false,
isGitRepo: true,
isWorktree: false,
+ vcs: "git" as const,
async getDefaultBranch() {
return "main";
},
diff --git a/apps/mobile/src/data/test/fixtures.ts b/apps/mobile/src/data/test/fixtures.ts
index c14bfd23eb..7dcdf33cce 100644
--- a/apps/mobile/src/data/test/fixtures.ts
+++ b/apps/mobile/src/data/test/fixtures.ts
@@ -57,6 +57,7 @@ export function threadListEntry(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null },
...overrides,
};
diff --git a/apps/server/src/routes/environments.ts b/apps/server/src/routes/environments.ts
index f5a53b85cc..14952c48f3 100644
--- a/apps/server/src/routes/environments.ts
+++ b/apps/server/src/routes/environments.ts
@@ -86,6 +86,24 @@ async function mapNoChangesTo409(
}
}
+/**
+ * Maps the daemon's typed `jj_workspace` failure (the workspace is a
+ * colocated Jujutsu checkout, where a git commit would strand the change as a
+ * stray head in jj's log) to a 409 the client can present as-is.
+ */
+async function mapJjWorkspaceTo409(
+ run: () => Promise,
+): Promise {
+ try {
+ return await run();
+ } catch (error) {
+ if (error instanceof ApiError && error.body.code === "jj_workspace") {
+ throw new ApiError(409, "jj_workspace", error.body.message);
+ }
+ throw error;
+ }
+}
+
async function mapPullRequestActionFailureTo409(
run: () => Promise,
): Promise {
@@ -697,9 +715,8 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void {
});
const commitMessage = aiMessage ?? COMMIT_FALLBACK_MESSAGE;
- const result = await mapNoChangesTo409(
- "No uncommitted changes to commit",
- () =>
+ const result = await mapJjWorkspaceTo409(() =>
+ mapNoChangesTo409("No uncommitted changes to commit", () =>
runLiveCommandAndWait(deps, {
hostId: target.hostId,
timeoutMs: COMMAND_TIMEOUT_MS,
@@ -710,6 +727,7 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void {
message: commitMessage,
},
}),
+ ),
);
return context.json({
ok: true,
@@ -731,7 +749,12 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void {
const workspaceStatus = requireAvailableWorkspaceStatus(statusResult);
const currentBranch = workspaceStatus.branch.currentBranch;
- if (!currentBranch) {
+ // A jj workspace has no git branch: its work is named by a bookmark,
+ // and the daemon resolves what to merge from jj itself.
+ const checkout = workspaceStatus.checkout;
+ const jjBookmark =
+ checkout.kind === "detached" ? checkout.jj : undefined;
+ if (!currentBranch && !jjBookmark) {
throw new ApiError(
409,
"invalid_request",
@@ -787,7 +810,7 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void {
const workspaceDiff = requireAvailableWorkspaceDiff(diffResult);
const aiMessage = await generateCommitMessage(deps, {
- diffDescription: `squash merge of ${currentBranch} into ${targetBranch}`,
+ diffDescription: `squash merge of ${currentBranch ?? jjBookmark?.bookmark ?? "this workspace"} into ${targetBranch}`,
shortstat: workspaceDiff.shortstat,
files: workspaceDiff.files,
patch: workspaceDiff.diff,
diff --git a/apps/server/src/services/environments/environment-provisioning-internal.ts b/apps/server/src/services/environments/environment-provisioning-internal.ts
index 79a8f67129..fb4817d255 100644
--- a/apps/server/src/services/environments/environment-provisioning-internal.ts
+++ b/apps/server/src/services/environments/environment-provisioning-internal.ts
@@ -600,6 +600,7 @@ export function settleEnvironmentProvisionCommandResult(
path: args.report.result.path,
isGitRepo: args.report.result.isGitRepo,
isWorktree: args.report.result.isWorktree,
+ vcs: args.report.result.vcs,
branchName: args.report.result.branchName,
defaultBranch: args.report.result.defaultBranch,
...resolveProvisionedEnvironmentBranchMetadata(args.command),
diff --git a/apps/server/src/services/threads/thread-provisioning-environment.ts b/apps/server/src/services/threads/thread-provisioning-environment.ts
index 035c44e3cf..33d3689792 100644
--- a/apps/server/src/services/threads/thread-provisioning-environment.ts
+++ b/apps/server/src/services/threads/thread-provisioning-environment.ts
@@ -13,6 +13,7 @@ import {
type Environment,
type ProvisioningTranscriptEntry,
type Thread,
+ managedCheckoutNoun,
} from "@bb/domain";
import type { BaseBranchSpec, UnmanagedBranchSpec } from "@bb/server-contract";
import type { AppDeps } from "../../types.js";
@@ -91,11 +92,18 @@ type NewThreadProvisionEnvironmentIntent = Exclude<
{ type: "reuse" } | { type: "checkout-unmanaged" }
>;
-const INITIAL_PROVISIONING_TEXT_BY_WORKSPACE_TYPE = {
- unmanaged: "Preparing workspace",
- "managed-worktree": "Preparing worktree",
- personal: "Preparing personal workspace",
-} satisfies Record;
+function initialProvisioningText(
+ environment: Pick,
+): string {
+ switch (environment.workspaceProvisionType) {
+ case "unmanaged":
+ return "Preparing workspace";
+ case "managed-worktree":
+ return `Preparing ${managedCheckoutNoun(environment.vcs)}`;
+ case "personal":
+ return "Preparing personal workspace";
+ }
+}
interface EnsureWorkspaceReadyEventArgs {
context?: ThreadProvisionAttachableContext;
@@ -255,15 +263,13 @@ interface ThreadProvisionReadyEnvironment {
}
function initialProvisioningEntries(
- environment: Pick,
+ environment: Pick,
): ProvisioningTranscriptEntry[] {
return [
{
type: "step",
key: "workspace-started",
- text: INITIAL_PROVISIONING_TEXT_BY_WORKSPACE_TYPE[
- environment.workspaceProvisionType
- ],
+ text: initialProvisioningText(environment),
status: "started",
},
];
diff --git a/apps/server/src/services/threads/thread-runtime-display.ts b/apps/server/src/services/threads/thread-runtime-display.ts
index a47bd88247..6da89af12e 100644
--- a/apps/server/src/services/threads/thread-runtime-display.ts
+++ b/apps/server/src/services/threads/thread-runtime-display.ts
@@ -586,6 +586,7 @@ function toThreadListEntryResponseFromLatestSession(
environmentName: args.thread.environmentName,
environmentWorkspaceDisplayKind:
args.thread.environmentWorkspaceDisplayKind,
+ environmentVcs: args.thread.environmentVcs,
hasPendingInteraction: args.thread.hasPendingInteraction,
runtime: resolveThreadRuntimeStateFromLatestSession({
environmentHostId: args.thread.environmentHostId,
diff --git a/apps/server/src/services/threads/thread-turn-dispatch.ts b/apps/server/src/services/threads/thread-turn-dispatch.ts
index 02fa6b2538..7a32de47e3 100644
--- a/apps/server/src/services/threads/thread-turn-dispatch.ts
+++ b/apps/server/src/services/threads/thread-turn-dispatch.ts
@@ -29,6 +29,7 @@ import {
import { requestThreadReprovision } from "./thread-provisioning.js";
import { applyLoggedThreadLifecycleEvent } from "./lifecycle-outcome.js";
import { applyLoggedEnvironmentLifecycleEvent } from "../environments/lifecycle-outcome.js";
+import { managedCheckoutNoun } from "@bb/domain";
export interface ReadyThreadEnvironment extends Environment {
path: string;
@@ -52,12 +53,10 @@ interface DispatchTurnDuringReprovisionArgs {
thread: Thread;
}
-function reprovisionStartedText(
- workspaceProvisionType: Environment["workspaceProvisionType"],
-): string {
- switch (workspaceProvisionType) {
+function reprovisionStartedText(environment: Environment): string {
+ switch (environment.workspaceProvisionType) {
case "managed-worktree":
- return "Restoring worktree";
+ return `Restoring ${managedCheckoutNoun(environment.vcs)}`;
case "personal":
return "Restoring personal workspace";
case "unmanaged":
@@ -153,7 +152,7 @@ export async function dispatchTurnDuringReprovision(
{
type: "step",
key: "workspace-restore-started",
- text: reprovisionStartedText(args.environment.workspaceProvisionType),
+ text: reprovisionStartedText(args.environment),
status: "started",
},
],
diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts
index 2fa77d4f0d..2fa159cf4f 100644
--- a/apps/server/test/services/threads/thread-runtime-display.test.ts
+++ b/apps/server/test/services/threads/thread-runtime-display.test.ts
@@ -216,6 +216,7 @@ function createThreadListEntry(
environmentHostId: args.environmentHostId,
environmentName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
hasPendingInteraction: false,
};
}
diff --git a/docs/worktrees.md b/docs/worktrees.md
index 8ed3fccc55..91feaa4e08 100644
--- a/docs/worktrees.md
+++ b/docs/worktrees.md
@@ -123,6 +123,59 @@ an editor terminal. Each process gets `SIGTERM`, then `SIGKILL` after a
short grace period. Move your own shells out of the worktree before you
delete the environment if you want to keep them.
+## Jujutsu (colocated) repositories
+
+bb supports [Jujutsu](https://jj-vcs.github.io/jj/) repositories that are
+colocated with git (`jj git init --colocate` or `jj git clone --colocate`,
+so a real `.git` sits beside `.jj`). jj keeps that `.git` in sync — HEAD is
+pinned detached at the working-copy parent and bookmarks export as git
+branches — which is what bb reads.
+
+### Threads get a real jj workspace
+
+When a thread's source repository is a colocated jj repository, bb creates the
+managed checkout with `jj workspace add` instead of `git worktree add`. The
+thread's work is jj-native: it shows up as that workspace's `@` in `jj log`,
+`jj op log` can undo it, and `jj workspace list` in your repository shows where
+it lives.
+
+- Committing from bb runs `jj commit`, moves the workspace's `bb/...` bookmark
+ to the new commit, and exports it so the bookmark is also a git branch you
+ can push or open a pull request from.
+- Discarding changes runs `jj restore`.
+- Squash-merging into a branch works as it does for git worktrees, and the
+ target bookmark moves with it.
+- Commits an agent makes by running jj itself are picked up automatically.
+
+A jj workspace has no `.git` of its own, so bb registers it as a git worktree
+alongside jj and keeps that checkout pinned at `@-`. That is what lets status,
+diffs and file reads keep working; jj remains the only thing writing to the
+working copy.
+
+One consequence: a plain `git commit` run inside the workspace (by you or by an
+agent) doesn't stick. jj never sees it, and the next time bb reads the
+workspace the changes show up as uncommitted again — nothing is lost, but the
+commit is. Use jj, or bb's own commit action, to commit there.
+
+bb also calls these checkouts what jj calls them. In a jj project the sidebar,
+the environment picker, the thread panel, the provisioning transcript and
+`bb environment show` all say "workspace" where a git project says "worktree".
+
+### The main workspace
+
+Opening your repository directly (an unmanaged environment) still reads through
+the colocated `.git`: status and diffs show jj's working-copy changes, and the
+checkout row shows the bookmark at the current commit rather than "detached".
+Two actions stay disabled there, because jj manages that checkout: committing
+(a git commit would strand the previous working-copy change as an anonymous
+head in `jj log`) and branch switching. Use jj for both.
+
+Not supported:
+
+- Pure jj repositories without a colocated `.git`. The colocated git store is
+ what bb reads diffs and history from, so bb treats these as non-git
+ directories.
+
## If something isn't working
A few quick checks:
diff --git a/packages/client-core/test/machineThreadGroups.test.ts b/packages/client-core/test/machineThreadGroups.test.ts
index 2d748dfbe2..a7a86b7db5 100644
--- a/packages/client-core/test/machineThreadGroups.test.ts
+++ b/packages/client-core/test/machineThreadGroups.test.ts
@@ -40,6 +40,7 @@ function createThread(overrides: Partial): ThreadListEntry {
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/packages/client-core/test/pinnedSidebarThreads.test.ts b/packages/client-core/test/pinnedSidebarThreads.test.ts
index 927caf887b..c0ea57ba12 100644
--- a/packages/client-core/test/pinnedSidebarThreads.test.ts
+++ b/packages/client-core/test/pinnedSidebarThreads.test.ts
@@ -41,6 +41,7 @@ function createThread(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/packages/client-core/test/projectThreadGroups.test.ts b/packages/client-core/test/projectThreadGroups.test.ts
index 7f406aa4cb..cdbfbd5c2a 100644
--- a/packages/client-core/test/projectThreadGroups.test.ts
+++ b/packages/client-core/test/projectThreadGroups.test.ts
@@ -79,6 +79,7 @@ function createThread(
environmentName: null,
environmentBranchName: null,
environmentWorkspaceDisplayKind: "other",
+ environmentVcs: null,
runtime: {
displayStatus: "idle",
hostReconnectGraceExpiresAt: null,
diff --git a/packages/core-ui/src/environment-display.ts b/packages/core-ui/src/environment-display.ts
index 6f63759e7f..70be3edb59 100644
--- a/packages/core-ui/src/environment-display.ts
+++ b/packages/core-ui/src/environment-display.ts
@@ -1,5 +1,10 @@
import type { Environment, EnvironmentWorkspaceDisplayKind } from "@bb/domain";
-import { resolveEnvironmentWorkspaceDisplayKind } from "@bb/domain";
+import type { GitCheckoutRef } from "@bb/domain";
+import {
+ managedCheckoutNoun,
+ resolveEnvironmentWorkspaceDisplayKind,
+ resolveWorkspaceVcs,
+} from "@bb/domain";
type EnvironmentDisplayHostLocality = "local" | "remote";
@@ -22,7 +27,8 @@ export interface EnvironmentDisplayInfo {
* Human-readable environment label: a custom environment name when present,
* "Provisioning" while the environment is still being set up, "Destroying"
* while it is torn down, "Destroyed" once it is gone, otherwise "Working
- * locally", "Working remotely", or "Worktree".
+ * locally", "Working remotely", or the checkout's own name — "Worktree" for
+ * git, "Workspace" for jj.
*/
modeLabel: string;
/**
@@ -38,6 +44,11 @@ export interface EnvironmentDisplayInfo {
interface FormatEnvironmentDisplayArgs {
environment: Environment;
+ /**
+ * Live checkout for this environment, when the caller has it. Used only to
+ * recognize a jj workspace whose environment row predates bb recording it.
+ */
+ checkout?: GitCheckoutRef | null;
host: EnvironmentDisplayHostContext;
}
@@ -46,6 +57,7 @@ interface FormatEnvironmentDisplayArgs {
*/
export function formatEnvironmentDisplay({
environment,
+ checkout,
host,
}: FormatEnvironmentDisplayArgs): EnvironmentDisplayInfo {
const mode: EnvironmentDisplayInfo["mode"] = environment.isWorktree
@@ -80,19 +92,23 @@ export function formatEnvironmentDisplay({
host.locality === "remote" ? "Working remotely" : "Working locally";
const directCompactModeLabel =
host.locality === "remote" ? "Remote" : "Local";
+ const checkoutLabel = managedCheckoutNoun(
+ resolveWorkspaceVcs({ vcs: environment.vcs, checkout }),
+ { capitalized: true },
+ );
const generatedModeLabel = goneLabel
? goneLabel
: isProvisioningDisplay
? "Provisioning"
: mode === "worktree"
- ? "Worktree"
+ ? checkoutLabel
: directModeLabel;
const generatedCompactModeLabel = goneLabel
? goneLabel
: isProvisioningDisplay
? "Provisioning"
: mode === "worktree"
- ? "Worktree"
+ ? checkoutLabel
: directCompactModeLabel;
const modeLabel = environment.name ?? generatedModeLabel;
const compactModeLabel = environment.name ?? generatedCompactModeLabel;
diff --git a/packages/core-ui/test/environment-display.test.ts b/packages/core-ui/test/environment-display.test.ts
index 12fc0b0b91..ad0689bb22 100644
--- a/packages/core-ui/test/environment-display.test.ts
+++ b/packages/core-ui/test/environment-display.test.ts
@@ -25,6 +25,7 @@ function makeEnvironment(overrides?: Partial): Environment {
managed: false,
isGitRepo: true,
isWorktree: false,
+ vcs: null,
workspaceProvisionType: "unmanaged",
baseBranch: null,
branchName: null,
@@ -53,6 +54,50 @@ describe("formatEnvironmentDisplay", () => {
});
});
+ it("calls a jj managed checkout a workspace, not a worktree", () => {
+ const result = formatEnvironmentDisplay({
+ environment: makeEnvironment({
+ isWorktree: true,
+ workspaceProvisionType: "managed-worktree",
+ vcs: "jj",
+ }),
+ host: localHostContext,
+ });
+ expect(result.modeLabel).toBe("Workspace");
+ expect(result.compactModeLabel).toBe("Workspace");
+ });
+
+ it("recognizes a jj workspace whose row predates bb recording the tool", () => {
+ // Environments provisioned before the vcs column exists read as null
+ // until the daemon's next metadata refresh backfills them.
+ const result = formatEnvironmentDisplay({
+ environment: makeEnvironment({
+ isWorktree: true,
+ workspaceProvisionType: "managed-worktree",
+ vcs: null,
+ }),
+ checkout: {
+ kind: "detached",
+ headSha: "faecdf2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ jj: { bookmark: null },
+ },
+ host: localHostContext,
+ });
+ expect(result.modeLabel).toBe("Workspace");
+ });
+
+ it("keeps calling a git managed checkout a worktree", () => {
+ const result = formatEnvironmentDisplay({
+ environment: makeEnvironment({
+ isWorktree: true,
+ workspaceProvisionType: "managed-worktree",
+ vcs: "git",
+ }),
+ host: localHostContext,
+ });
+ expect(result.modeLabel).toBe("Worktree");
+ });
+
it("returns a remote label for remote unmanaged workspace", () => {
const result = formatEnvironmentDisplay({
environment: makeEnvironment(),
diff --git a/packages/db/drizzle/0110_familiar_whistler.sql b/packages/db/drizzle/0110_familiar_whistler.sql
new file mode 100644
index 0000000000..b0381654f8
--- /dev/null
+++ b/packages/db/drizzle/0110_familiar_whistler.sql
@@ -0,0 +1 @@
+ALTER TABLE `environments` ADD `vcs` text;
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0110_snapshot.json b/packages/db/drizzle/meta/0110_snapshot.json
new file mode 100644
index 0000000000..aaaaf4e417
--- /dev/null
+++ b/packages/db/drizzle/meta/0110_snapshot.json
@@ -0,0 +1,3805 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "8b173a47-9d15-4ef7-ac15-42fda83c37e2",
+ "prevId": "e66624da-3e23-42f0-9760-275442c0d59b",
+ "tables": {
+ "app_settings": {
+ "name": "app_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "caffeinate": {
+ "name": "caffeinate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_keyboard_hints": {
+ "name": "show_keyboard_hints",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "steer_active_thread_on_enter": {
+ "name": "steer_active_thread_on_enter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "show_unhandled_provider_events": {
+ "name": "show_unhandled_provider_events",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "codex_memory_enabled": {
+ "name": "codex_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "claude_code_memory_enabled": {
+ "name": "claude_code_memory_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "codex_subagents_disabled": {
+ "name": "codex_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_subagents_disabled": {
+ "name": "claude_code_subagents_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claude_code_workflows_disabled": {
+ "name": "claude_code_workflows_disabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "keybinding_overrides": {
+ "name": "keybinding_overrides",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'[]'"
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app_settings_values": {
+ "name": "app_settings_values",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app_theme": {
+ "name": "app_theme",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_id": {
+ "name": "theme_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "favicon_color": {
+ "name": "favicon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apikey": {
+ "name": "apikey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "start": {
+ "name": "start",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "prefix": {
+ "name": "prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "referenceId": {
+ "name": "referenceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refillInterval": {
+ "name": "refillInterval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refillAmount": {
+ "name": "refillAmount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRefillAt": {
+ "name": "lastRefillAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitEnabled": {
+ "name": "rateLimitEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitTimeWindow": {
+ "name": "rateLimitTimeWindow",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rateLimitMax": {
+ "name": "rateLimitMax",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requestCount": {
+ "name": "requestCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastRequest": {
+ "name": "lastRequest",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "configId": {
+ "name": "configId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apikey_key_unique": {
+ "name": "apikey_key_unique",
+ "columns": [
+ "key"
+ ],
+ "isUnique": true
+ },
+ "apikey_reference_id_idx": {
+ "name": "apikey_reference_id_idx",
+ "columns": [
+ "referenceId"
+ ],
+ "isUnique": false
+ },
+ "apikey_config_id_idx": {
+ "name": "apikey_config_id_idx",
+ "columns": [
+ "configId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "apikey_referenceId_user_id_fk": {
+ "name": "apikey_referenceId_user_id_fk",
+ "tableFrom": "apikey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "referenceId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "deferred_thread_messages": {
+ "name": "deferred_thread_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "deferred_thread_messages_thread_created_idx": {
+ "name": "deferred_thread_messages_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "deferred_thread_messages_thread_id_threads_id_fk": {
+ "name": "deferred_thread_messages_thread_id_threads_id_fk",
+ "tableFrom": "deferred_thread_messages",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "environments": {
+ "name": "environments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_git_repo": {
+ "name": "is_git_repo",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "is_worktree": {
+ "name": "is_worktree",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "vcs": {
+ "name": "vcs",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "base_branch": {
+ "name": "base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_branch": {
+ "name": "default_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "merge_base_branch": {
+ "name": "merge_base_branch",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "destroy_attempt_id": {
+ "name": "destroy_attempt_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "retire_requested_at": {
+ "name": "retire_requested_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workspace_provision_type": {
+ "name": "workspace_provision_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provisioning'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "environments_project_host_path_idx": {
+ "name": "environments_project_host_path_idx",
+ "columns": [
+ "project_id",
+ "host_id",
+ "path"
+ ],
+ "isUnique": true
+ },
+ "environments_host_path_lookup_idx": {
+ "name": "environments_host_path_lookup_idx",
+ "columns": [
+ "host_id",
+ "path"
+ ],
+ "isUnique": false
+ },
+ "environments_project_idx": {
+ "name": "environments_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "environments_status_idx": {
+ "name": "environments_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "environments_project_id_projects_id_fk": {
+ "name": "environments_project_id_projects_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environments_host_id_hosts_id_fk": {
+ "name": "environments_host_id_hosts_id_fk",
+ "tableFrom": "environments",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "events": {
+ "name": "events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parent_tool_call_id": {
+ "name": "parent_tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "data": {
+ "name": "data",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "events_thread_sequence_idx": {
+ "name": "events_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": true
+ },
+ "events_delegating_item_lookup_idx": {
+ "name": "events_delegating_item_lookup_idx",
+ "columns": [
+ "thread_id",
+ "item_id",
+ "sequence",
+ "item_kind"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')"
+ },
+ "events_plan_steps_thread_sequence_idx": {
+ "name": "events_plan_steps_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'"
+ },
+ "events_parent_tool_call_thread_parent_sequence_idx": {
+ "name": "events_parent_tool_call_thread_parent_sequence_idx",
+ "columns": [
+ "thread_id",
+ "parent_tool_call_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL"
+ },
+ "events_thread_type_item_kind_sequence_idx": {
+ "name": "events_thread_type_item_kind_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_kind",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_background_task_thread_type_item_sequence_idx": {
+ "name": "events_background_task_thread_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"item_kind\" = 'backgroundTask'"
+ },
+ "events_thread_type_sequence_idx": {
+ "name": "events_thread_type_sequence_idx",
+ "columns": [
+ "thread_id",
+ "type",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_thread_turn_type_item_sequence_idx": {
+ "name": "events_thread_turn_type_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "turn_id",
+ "type",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false
+ },
+ "events_item_lifecycle_thread_item_sequence_idx": {
+ "name": "events_item_lifecycle_thread_item_sequence_idx",
+ "columns": [
+ "thread_id",
+ "item_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')"
+ },
+ "events_environment_idx": {
+ "name": "events_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "events_completed_item_truncation_idx": {
+ "name": "events_completed_item_truncation_idx",
+ "columns": [
+ "item_kind",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" = 'item/completed'"
+ },
+ "events_thread_state_thread_sequence_idx": {
+ "name": "events_thread_state_thread_sequence_idx",
+ "columns": [
+ "thread_id",
+ "sequence"
+ ],
+ "isUnique": false,
+ "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')"
+ }
+ },
+ "foreignKeys": {
+ "events_thread_id_threads_id_fk": {
+ "name": "events_thread_id_threads_id_fk",
+ "tableFrom": "events",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "events_environment_id_environments_id_fk": {
+ "name": "events_environment_id_environments_id_fk",
+ "tableFrom": "events",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "events_scope_shape_check": {
+ "name": "events_scope_shape_check",
+ "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )"
+ }
+ }
+ },
+ "host_daemon_sessions": {
+ "name": "host_daemon_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_name": {
+ "name": "host_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_type": {
+ "name": "host_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "data_dir": {
+ "name": "data_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol_version": {
+ "name": "protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "heartbeat_interval_ms": {
+ "name": "heartbeat_interval_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_timeout_ms": {
+ "name": "lease_timeout_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "host_daemon_sessions_host_status_idx": {
+ "name": "host_daemon_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_host_latest_idx": {
+ "name": "host_daemon_sessions_host_latest_idx",
+ "columns": [
+ "host_id",
+ "updated_at",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "host_daemon_sessions_closed_prune_idx": {
+ "name": "host_daemon_sessions_closed_prune_idx",
+ "columns": [
+ "status",
+ "closed_at",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "host_daemon_sessions_host_id_hosts_id_fk": {
+ "name": "host_daemon_sessions_host_id_hosts_id_fk",
+ "tableFrom": "host_daemon_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "hosts": {
+ "name": "hosts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "connect_machine_id": {
+ "name": "connect_machine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_permission_mode": {
+ "name": "max_permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'full'"
+ },
+ "destroyed_at": {
+ "name": "destroyed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_rejected_protocol_version": {
+ "name": "last_rejected_protocol_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "hosts_last_seen_idx": {
+ "name": "hosts_last_seen_idx",
+ "columns": [
+ "last_seen_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugins": {
+ "name": "plugins",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'direct'"
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "catalog_marketplace_name": {
+ "name": "catalog_marketplace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'path'"
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_builtin_name": {
+ "name": "source_builtin_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_package": {
+ "name": "source_npm_package",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_registry": {
+ "name": "source_npm_registry",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_requested_spec": {
+ "name": "source_npm_requested_spec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_npm_spec_kind": {
+ "name": "source_npm_spec_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_url": {
+ "name": "source_git_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_subdirectory": {
+ "name": "source_git_subdirectory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_requested_ref": {
+ "name": "source_git_requested_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_ref_kind": {
+ "name": "source_git_ref_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_range": {
+ "name": "source_git_range",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_tag_prefix": {
+ "name": "source_git_tag_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_resolved_tag": {
+ "name": "source_git_resolved_tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "npm_integrity": {
+ "name": "npm_integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_update_check_at": {
+ "name": "last_update_check_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "available_compatible_version": {
+ "name": "available_compatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "newest_incompatible_version": {
+ "name": "newest_incompatible_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "update_status_detail": {
+ "name": "update_status_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_version": {
+ "name": "last_failure_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_at": {
+ "name": "last_failure_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_failure_detail": {
+ "name": "last_failure_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "active_artifact_id": {
+ "name": "active_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "normalization_version": {
+ "name": "normalization_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "root_dir": {
+ "name": "root_dir",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "plugins_active_artifact_id_plugin_artifacts_id_fk": {
+ "name": "plugins_active_artifact_id_plugin_artifacts_id_fk",
+ "tableFrom": "plugins",
+ "tableTo": "plugin_artifacts",
+ "columnsFrom": [
+ "active_artifact_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "maintenance_scan_cursors": {
+ "name": "maintenance_scan_cursors",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_kind": {
+ "name": "item_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "output_path": {
+ "name": "output_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_created_at": {
+ "name": "last_created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_event_id": {
+ "name": "last_event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "maintenance_scan_cursors_path_idx": {
+ "name": "maintenance_scan_cursors_path_idx",
+ "columns": [
+ "policy",
+ "version",
+ "item_kind",
+ "output_path"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pending_interactions": {
+ "name": "pending_interactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'provider'"
+ },
+ "turn_id": {
+ "name": "turn_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_thread_id": {
+ "name": "provider_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_request_id": {
+ "name": "provider_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "renderer_id": {
+ "name": "renderer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolution": {
+ "name": "resolution",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status_reason": {
+ "name": "status_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "pending_interactions_provider_request_idx": {
+ "name": "pending_interactions_provider_request_idx",
+ "columns": [
+ "provider_id",
+ "provider_thread_id",
+ "provider_request_id"
+ ],
+ "isUnique": true
+ },
+ "pending_interactions_thread_created_idx": {
+ "name": "pending_interactions_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_thread_status_created_idx": {
+ "name": "pending_interactions_thread_status_created_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_status_created_idx": {
+ "name": "pending_interactions_status_created_idx",
+ "columns": [
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ },
+ "pending_interactions_plugin_status_created_idx": {
+ "name": "pending_interactions_plugin_status_created_idx",
+ "columns": [
+ "plugin_id",
+ "status",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "pending_interactions_thread_id_threads_id_fk": {
+ "name": "pending_interactions_thread_id_threads_id_fk",
+ "tableFrom": "pending_interactions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_artifacts": {
+ "name": "plugin_artifacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "npm_resolved_version": {
+ "name": "npm_resolved_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_resolved_commit": {
+ "name": "git_resolved_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "git_checkout_root": {
+ "name": "git_checkout_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integrity": {
+ "name": "integrity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "validation_result": {
+ "name": "validation_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "validated_at": {
+ "name": "validated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_artifacts_plugin_idx": {
+ "name": "plugin_artifacts_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_kv": {
+ "name": "plugin_kv",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_kv_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_kv_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_marketplace_icons": {
+ "name": "plugin_marketplace_icons",
+ "columns": {
+ "marketplace_name": {
+ "name": "marketplace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entry_id": {
+ "name": "entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_marketplace_icons_marketplace_name_entry_id_pk": {
+ "columns": [
+ "marketplace_name",
+ "entry_id"
+ ],
+ "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_marketplaces": {
+ "name": "plugin_marketplaces",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'https'"
+ },
+ "manifest_url": {
+ "name": "manifest_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_git_ref": {
+ "name": "source_git_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_git_commit": {
+ "name": "source_git_commit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "manifest_json": {
+ "name": "manifest_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stats_json": {
+ "name": "stats_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_successful_refresh_at": {
+ "name": "last_successful_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_attempted_refresh_at": {
+ "name": "last_attempted_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_schedules": {
+ "name": "plugin_schedules",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_schedules_plugin_id_name_pk": {
+ "columns": [
+ "plugin_id",
+ "name"
+ ],
+ "name": "plugin_schedules_plugin_id_name_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_settings": {
+ "name": "plugin_settings",
+ "columns": {
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "plugin_settings_plugin_id_key_pk": {
+ "columns": [
+ "plugin_id",
+ "key"
+ ],
+ "name": "plugin_settings_plugin_id_key_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugin_state_snapshots": {
+ "name": "plugin_state_snapshots",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "from_artifact_id": {
+ "name": "from_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "to_artifact_id": {
+ "name": "to_artifact_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot_path": {
+ "name": "snapshot_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "database_path": {
+ "name": "database_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state_path": {
+ "name": "state_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "secrets_path": {
+ "name": "secrets_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "registration_path": {
+ "name": "registration_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rollback_candidate_version": {
+ "name": "rollback_candidate_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_source_fingerprint": {
+ "name": "rollback_source_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_bb_version": {
+ "name": "rollback_bb_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_sdk_version": {
+ "name": "rollback_sdk_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rollback_detail": {
+ "name": "rollback_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retained_until": {
+ "name": "retained_until",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "plugin_state_snapshots_plugin_idx": {
+ "name": "plugin_state_snapshots_plugin_idx",
+ "columns": [
+ "plugin_id"
+ ],
+ "isUnique": false
+ },
+ "plugin_state_snapshots_retention_idx": {
+ "name": "plugin_state_snapshots_retention_idx",
+ "columns": [
+ "retained_until"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_execution_defaults": {
+ "name": "project_execution_defaults",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_execution_defaults_project_idx": {
+ "name": "project_execution_defaults_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_execution_defaults_project_id_projects_id_fk": {
+ "name": "project_execution_defaults_project_id_projects_id_fk",
+ "tableFrom": "project_execution_defaults",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "project_sources": {
+ "name": "project_sources",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "project_sources_project_idx": {
+ "name": "project_sources_project_idx",
+ "columns": [
+ "project_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_host_idx": {
+ "name": "project_sources_host_idx",
+ "columns": [
+ "host_id"
+ ],
+ "isUnique": false
+ },
+ "project_sources_project_host_idx": {
+ "name": "project_sources_project_host_idx",
+ "columns": [
+ "project_id",
+ "host_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "project_sources_project_id_projects_id_fk": {
+ "name": "project_sources_project_id_projects_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_sources_host_id_hosts_id_fk": {
+ "name": "project_sources_host_id_hosts_id_fk",
+ "tableFrom": "project_sources",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "project_sources_shape_check": {
+ "name": "project_sources_shape_check",
+ "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )"
+ }
+ }
+ },
+ "projects": {
+ "name": "projects",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'standard'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "git_remote_url": {
+ "name": "git_remote_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'V'"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "projects_updated_idx": {
+ "name": "projects_updated_idx",
+ "columns": [
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "projects_deleted_idx": {
+ "name": "projects_deleted_idx",
+ "columns": [
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "projects_sort_idx": {
+ "name": "projects_sort_idx",
+ "columns": [
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "projects_personal_singleton_idx": {
+ "name": "projects_personal_singleton_idx",
+ "columns": [
+ "kind"
+ ],
+ "isUnique": true,
+ "where": "\"projects\".\"kind\" = 'personal'"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "prompt_history_entries": {
+ "name": "prompt_history_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_sequence": {
+ "name": "request_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input": {
+ "name": "input",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "prompt_history_entries_thread_request_idx": {
+ "name": "prompt_history_entries_thread_request_idx",
+ "columns": [
+ "thread_id",
+ "request_sequence"
+ ],
+ "isUnique": true
+ },
+ "prompt_history_entries_project_scope_created_idx": {
+ "name": "prompt_history_entries_project_scope_created_idx",
+ "columns": [
+ "project_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "prompt_history_entries_thread_scope_created_idx": {
+ "name": "prompt_history_entries_thread_scope_created_idx",
+ "columns": [
+ "thread_id",
+ "scope",
+ "created_at",
+ "request_sequence",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "prompt_history_entries_project_id_projects_id_fk": {
+ "name": "prompt_history_entries_project_id_projects_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "prompt_history_entries_thread_id_threads_id_fk": {
+ "name": "prompt_history_entries_thread_id_threads_id_fk",
+ "tableFrom": "prompt_history_entries",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "queued_thread_messages": {
+ "name": "queued_thread_messages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sender_thread_id": {
+ "name": "sender_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reasoning_level": {
+ "name": "reasoning_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission_mode": {
+ "name": "permission_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "service_tier": {
+ "name": "service_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_with_next": {
+ "name": "group_with_next",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "claim_token": {
+ "name": "claim_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sort_key": {
+ "name": "sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "queued_thread_messages_thread_created_idx": {
+ "name": "queued_thread_messages_thread_created_idx",
+ "columns": [
+ "thread_id",
+ "created_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "queued_thread_messages_thread_sort_idx": {
+ "name": "queued_thread_messages_thread_sort_idx",
+ "columns": [
+ "thread_id",
+ "sort_key",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "queued_thread_messages_thread_id_threads_id_fk": {
+ "name": "queued_thread_messages_thread_id_threads_id_fk",
+ "tableFrom": "queued_thread_messages",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "system_experiments": {
+ "name": "system_experiments",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "terminal_sessions": {
+ "name": "terminal_sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "host_id": {
+ "name": "host_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "daemon_session_id": {
+ "name": "daemon_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "initial_cwd": {
+ "name": "initial_cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cols": {
+ "name": "cols",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rows": {
+ "name": "rows",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exit_code": {
+ "name": "exit_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "close_reason": {
+ "name": "close_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_user_input_at": {
+ "name": "last_user_input_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "terminal_sessions_thread_status_updated_idx": {
+ "name": "terminal_sessions_thread_status_updated_idx",
+ "columns": [
+ "thread_id",
+ "status",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_environment_status_idx": {
+ "name": "terminal_sessions_environment_status_idx",
+ "columns": [
+ "environment_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_host_status_idx": {
+ "name": "terminal_sessions_host_status_idx",
+ "columns": [
+ "host_id",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "terminal_sessions_daemon_session_idx": {
+ "name": "terminal_sessions_daemon_session_idx",
+ "columns": [
+ "daemon_session_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "terminal_sessions_thread_id_threads_id_fk": {
+ "name": "terminal_sessions_thread_id_threads_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_environment_id_environments_id_fk": {
+ "name": "terminal_sessions_environment_id_environments_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_host_id_hosts_id_fk": {
+ "name": "terminal_sessions_host_id_hosts_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "hosts",
+ "columnsFrom": [
+ "host_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": {
+ "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk",
+ "tableFrom": "terminal_sessions",
+ "tableTo": "host_daemon_sessions",
+ "columnsFrom": [
+ "daemon_session_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_dynamic_context_file_states": {
+ "name": "thread_dynamic_context_file_states",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "file_key": {
+ "name": "file_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_status": {
+ "name": "content_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "shown_at": {
+ "name": "shown_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_dynamic_context_file_states_thread_file_idx": {
+ "name": "thread_dynamic_context_file_states_thread_file_idx",
+ "columns": [
+ "thread_id",
+ "file_key"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "thread_dynamic_context_file_states_thread_id_threads_id_fk": {
+ "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk",
+ "tableFrom": "thread_dynamic_context_file_states",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_search_segments": {
+ "name": "thread_search_segments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_key": {
+ "name": "source_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_seq": {
+ "name": "source_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_search_segments_source_idx": {
+ "name": "thread_search_segments_source_idx",
+ "columns": [
+ "thread_id",
+ "source_kind",
+ "source_key"
+ ],
+ "isUnique": true
+ },
+ "thread_search_segments_thread_source_seq_idx": {
+ "name": "thread_search_segments_thread_source_seq_idx",
+ "columns": [
+ "thread_id",
+ "source_seq"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "thread_search_segments_thread_id_threads_id_fk": {
+ "name": "thread_search_segments_thread_id_threads_id_fk",
+ "tableFrom": "thread_search_segments",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_sections": {
+ "name": "thread_sections",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "thread_sections_name_idx": {
+ "name": "thread_sections_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_tabs": {
+ "name": "thread_tabs",
+ "columns": {
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tabs_json": {
+ "name": "tabs_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_tabs_thread_id_threads_id_fk": {
+ "name": "thread_tabs_thread_id_threads_id_fk",
+ "tableFrom": "thread_tabs",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "threads": {
+ "name": "threads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model_override": {
+ "name": "model_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reasoning_level_override": {
+ "name": "reasoning_level_override",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title_fallback": {
+ "name": "title_fallback",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'starting'"
+ },
+ "parent_thread_id": {
+ "name": "parent_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_thread_id": {
+ "name": "source_thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "origin_plugin_id": {
+ "name": "origin_plugin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'visible'"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "pin_sort_key": {
+ "name": "pin_sort_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latest_attention_at": {
+ "name": "latest_attention_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "threads_project_updated_idx": {
+ "name": "threads_project_updated_idx",
+ "columns": [
+ "project_id",
+ "updated_at"
+ ],
+ "isUnique": false
+ },
+ "threads_project_archived_deleted_idx": {
+ "name": "threads_project_archived_deleted_idx",
+ "columns": [
+ "project_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_pin_sort_idx": {
+ "name": "threads_pin_sort_idx",
+ "columns": [
+ "archived_at",
+ "deleted_at",
+ "pin_sort_key",
+ "id"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"pinned_at\" IS NOT NULL"
+ },
+ "threads_environment_idx": {
+ "name": "threads_environment_idx",
+ "columns": [
+ "environment_id"
+ ],
+ "isUnique": false
+ },
+ "threads_parent_idx": {
+ "name": "threads_parent_idx",
+ "columns": [
+ "parent_thread_id"
+ ],
+ "isUnique": false
+ },
+ "threads_source_origin_idx": {
+ "name": "threads_source_origin_idx",
+ "columns": [
+ "source_thread_id",
+ "origin_kind"
+ ],
+ "isUnique": false
+ },
+ "threads_origin_plugin_archived_idx": {
+ "name": "threads_origin_plugin_archived_idx",
+ "columns": [
+ "origin_plugin_id",
+ "archived_at"
+ ],
+ "isUnique": false
+ },
+ "threads_section_archived_deleted_idx": {
+ "name": "threads_section_archived_deleted_idx",
+ "columns": [
+ "section_id",
+ "archived_at",
+ "deleted_at",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "threads_archived_status_idx": {
+ "name": "threads_archived_status_idx",
+ "columns": [
+ "archived_at",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "threads_environment_archived_deleted_idx": {
+ "name": "threads_environment_archived_deleted_idx",
+ "columns": [
+ "environment_id",
+ "archived_at",
+ "deleted_at"
+ ],
+ "isUnique": false
+ },
+ "threads_active_maintenance_idx": {
+ "name": "threads_active_maintenance_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false,
+ "where": "\"threads\".\"deleted_at\" IS NULL"
+ }
+ },
+ "foreignKeys": {
+ "threads_project_id_projects_id_fk": {
+ "name": "threads_project_id_projects_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "threads_environment_id_environments_id_fk": {
+ "name": "threads_environment_id_environments_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_section_id_thread_sections_id_fk": {
+ "name": "threads_section_id_thread_sections_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "thread_sections",
+ "columnsFrom": [
+ "section_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_parent_thread_id_threads_id_fk": {
+ "name": "threads_parent_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "parent_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "threads_source_thread_id_threads_id_fk": {
+ "name": "threads_source_thread_id_threads_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "threads",
+ "columnsFrom": [
+ "source_thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 1647a7b871..0bb9765115 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -771,6 +771,13 @@
"when": 1787680413251,
"tag": "0109_marketplace_install_stats",
"breakpoints": true
+ },
+ {
+ "idx": 110,
+ "version": "6",
+ "when": 1787817190257,
+ "tag": "0110_familiar_whistler",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/data/environments.ts b/packages/db/src/data/environments.ts
index 0f518524c1..7cb2684d98 100644
--- a/packages/db/src/data/environments.ts
+++ b/packages/db/src/data/environments.ts
@@ -6,6 +6,7 @@ import type {
EnvironmentLifecycleNoopReason,
EnvironmentStatus,
WorkspaceProvisionType,
+ WorkspaceVcs,
} from "@bb/domain";
import { evaluateEnvironmentLifecycleEvent } from "@bb/domain";
import type { DbConnection, DbTransaction } from "../connection.js";
@@ -26,6 +27,7 @@ export interface CreateEnvironmentInput {
managed?: boolean;
isGitRepo?: boolean;
isWorktree?: boolean;
+ vcs?: WorkspaceVcs | null;
branchName?: string | null;
baseBranch?: string | null;
defaultBranch?: string | null;
@@ -51,6 +53,7 @@ export function createEnvironment(
managed: input.managed ?? false,
isGitRepo: input.isGitRepo ?? false,
isWorktree: input.isWorktree ?? false,
+ vcs: input.vcs ?? null,
branchName: input.branchName ?? null,
baseBranch: input.baseBranch ?? null,
defaultBranch: input.defaultBranch ?? null,
@@ -144,6 +147,7 @@ interface EnvironmentMetadataUpdateColumns {
defaultBranch?: string | null;
isGitRepo?: boolean;
isWorktree?: boolean;
+ vcs?: WorkspaceVcs | null;
mergeBaseBranch?: string | null;
name?: string | null;
path?: string | null;
@@ -207,6 +211,7 @@ function buildEnvironmentMetadataUpdateSet(
if ("path" in input) set.path = input.path;
if ("isGitRepo" in input) set.isGitRepo = input.isGitRepo;
if ("isWorktree" in input) set.isWorktree = input.isWorktree;
+ if ("vcs" in input) set.vcs = input.vcs;
if ("branchName" in input) set.branchName = input.branchName;
if ("defaultBranch" in input) set.defaultBranch = input.defaultBranch;
if ("mergeBaseBranch" in input) set.mergeBaseBranch = input.mergeBaseBranch;
@@ -225,6 +230,7 @@ function environmentMetadataChanged(
args.updated.isGitRepo !== args.existing.isGitRepo) ||
("isWorktree" in args.metadata &&
args.updated.isWorktree !== args.existing.isWorktree) ||
+ ("vcs" in args.metadata && args.updated.vcs !== args.existing.vcs) ||
("branchName" in args.metadata &&
args.updated.branchName !== args.existing.branchName) ||
("defaultBranch" in args.metadata &&
@@ -306,6 +312,7 @@ export function recordProvisionedEnvironmentWorkspace(
path: input.path,
isGitRepo: input.isGitRepo,
isWorktree: input.isWorktree,
+ vcs: input.vcs,
branchName: input.branchName,
defaultBranch: input.defaultBranch,
...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}),
diff --git a/packages/db/src/data/threads.ts b/packages/db/src/data/threads.ts
index 33ceac44d2..0f8731d27d 100644
--- a/packages/db/src/data/threads.ts
+++ b/packages/db/src/data/threads.ts
@@ -25,6 +25,7 @@ import type {
ThreadStatus,
ThreadVisibility,
WorkspaceProvisionType,
+ WorkspaceVcs,
} from "@bb/domain";
import {
evaluateThreadLifecycleEvent,
@@ -535,6 +536,7 @@ function threadWithPendingInteractionBaseQuery(db: DbConnection) {
environmentHostId: environments.hostId,
environmentIsWorktree: environments.isWorktree,
environmentName: environments.name,
+ environmentVcs: environments.vcs,
environmentWorkspaceProvisionType: environments.workspaceProvisionType,
hasPendingInteraction: sql`EXISTS (SELECT 1 FROM ${pendingInteractions} WHERE ${pendingInteractions.threadId} = ${threads.id} AND ${pendingInteractions.status} = 'pending')`,
})
@@ -576,6 +578,7 @@ export interface ThreadWithPendingInteractionState extends ThreadRow {
environmentName: string | null;
hasPendingInteraction: boolean;
environmentWorkspaceDisplayKind: EnvironmentWorkspaceDisplayKind;
+ environmentVcs: WorkspaceVcs | null;
}
interface ThreadWithPendingInteractionStateRow extends ThreadRow {
@@ -583,6 +586,7 @@ interface ThreadWithPendingInteractionStateRow extends ThreadRow {
environmentHostId: string | null;
environmentIsWorktree: boolean | null;
environmentName: string | null;
+ environmentVcs: WorkspaceVcs | null;
environmentWorkspaceProvisionType: WorkspaceProvisionType | null;
hasPendingInteraction: number;
}
@@ -765,6 +769,7 @@ function toThreadWithPendingInteractionState(
environmentBranchName,
environmentHostId,
environmentName,
+ environmentVcs,
hasPendingInteraction,
...thread
} = row;
@@ -773,6 +778,7 @@ function toThreadWithPendingInteractionState(
environmentBranchName,
environmentHostId,
environmentName,
+ environmentVcs,
environmentWorkspaceDisplayKind: resolveEnvironmentWorkspaceDisplayKind({
environment: {
isWorktree: environmentIsWorktree,
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 82e0fa9643..d59bb6933b 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -31,6 +31,7 @@ import type {
ThreadEventScopeKind,
ThreadEventType,
WorkspaceProvisionType,
+ WorkspaceVcs,
ProjectKind,
} from "@bb/domain";
@@ -527,6 +528,9 @@ export const environments = sqliteTable(
isWorktree: integer("is_worktree", { mode: "boolean" })
.notNull()
.default(false),
+ // Null until the workspace is discovered, and for rows written before bb
+ // supported jj. Both read as git.
+ vcs: text("vcs").$type(),
branchName: text("branch_name"),
baseBranch: text("base_branch"),
defaultBranch: text("default_branch"),
diff --git a/packages/db/test/data/environments.test.ts b/packages/db/test/data/environments.test.ts
index e4db3bb7e9..1f59902192 100644
--- a/packages/db/test/data/environments.test.ts
+++ b/packages/db/test/data/environments.test.ts
@@ -132,6 +132,7 @@ describe("environments", () => {
path: "/tmp/project",
isGitRepo: true,
isWorktree: false,
+ vcs: "git",
branchName: "bb/test",
defaultBranch: "main",
},
diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts
index bc3966d342..05aebb1dd6 100644
--- a/packages/db/test/migrate.test.ts
+++ b/packages/db/test/migrate.test.ts
@@ -320,6 +320,7 @@ function dropRewindAddedTables(db: DbConnection): void {
.run();
dropHostMaxPermissionModeColumn(db);
dropEnvironmentRetireRequestedAtColumn(db);
+ dropEnvironmentVcsColumn(db);
dropPluginArtifactGitCheckoutRootColumn(db);
dropThreadSectionSchema(db);
restoreWideExperimentsTable(db);
@@ -761,6 +762,15 @@ function dropPluginArtifactGitCheckoutRootColumn(db: DbConnection): void {
}
}
+function dropEnvironmentVcsColumn(db: DbConnection): void {
+ const columns = db.$client
+ .prepare<[], TableInfoRow>("PRAGMA table_info(environments)")
+ .all();
+ if (columns.some((column) => column.name === "vcs")) {
+ db.$client.prepare("ALTER TABLE environments DROP COLUMN vcs").run();
+ }
+}
+
function dropEnvironmentRetireRequestedAtColumn(db: DbConnection): void {
const columns = db.$client
.prepare<[], TableInfoRow>("PRAGMA table_info(environments)")
@@ -829,6 +839,7 @@ function dropQueuedMessageSenderThreadIdColumn(db: DbConnection): void {
function dropPost0023Tables(db: DbConnection): void {
dropEventParentToolCallIdColumn(db);
dropEnvironmentRetireRequestedAtColumn(db);
+ dropEnvironmentVcsColumn(db);
dropPluginArtifactGitCheckoutRootColumn(db);
dropProjectGitRemoteUrlColumn(db);
db.$client.prepare("DROP TABLE IF EXISTS thread_tabs").run();
@@ -2036,6 +2047,7 @@ describe("migrate", () => {
dropNewOnboardingExperimentColumn(db);
dropHostMaxPermissionModeColumn(db);
dropEnvironmentRetireRequestedAtColumn(db);
+ dropEnvironmentVcsColumn(db);
dropPluginArtifactGitCheckoutRootColumn(db);
dropMarketplaceCatalogSchema(db);
dropEventParentToolCallIdColumn(db);
@@ -2440,6 +2452,7 @@ describe("migrate", () => {
dropNewOnboardingExperimentColumn(db);
dropHostMaxPermissionModeColumn(db);
dropEnvironmentRetireRequestedAtColumn(db);
+ dropEnvironmentVcsColumn(db);
dropPluginArtifactGitCheckoutRootColumn(db);
dropMarketplaceCatalogSchema(db);
dropEventParentToolCallIdColumn(db);
@@ -2541,6 +2554,7 @@ describe("migrate", () => {
dropNewOnboardingExperimentColumn(db);
dropHostMaxPermissionModeColumn(db);
dropEnvironmentRetireRequestedAtColumn(db);
+ dropEnvironmentVcsColumn(db);
dropPluginArtifactGitCheckoutRootColumn(db);
dropMarketplaceCatalogSchema(db);
dropEventParentToolCallIdColumn(db);
@@ -5134,6 +5148,7 @@ describe("migrate", () => {
dropEventParentToolCallIdColumn(db);
dropMarketplaceStatsColumn(db);
+ dropEnvironmentVcsColumn(db);
db.$client
.prepare(
"DELETE FROM __drizzle_migrations WHERE created_at >= ?",
diff --git a/packages/domain/src/environment.ts b/packages/domain/src/environment.ts
index 3538d4a673..e7fad376eb 100644
--- a/packages/domain/src/environment.ts
+++ b/packages/domain/src/environment.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import type { GitCheckoutRef } from "./git-checkout.js";
export const environmentStatusValues = [
"provisioning",
"ready",
@@ -53,6 +54,56 @@ export function resolveEnvironmentWorkspaceDisplayKind({
return "other";
}
+const workspaceVcsValues = ["git", "jj"] as const;
+/**
+ * Which tool owns a workspace's working copy. "jj" means a Jujutsu workspace,
+ * where bb reads through a git checkout it keeps alongside jj but commits with
+ * jj. It changes what bb calls the checkout in the interface — a workspace,
+ * not a worktree — and which commands it runs.
+ */
+export const workspaceVcsSchema = z.enum(workspaceVcsValues);
+export type WorkspaceVcs = z.infer;
+
+/**
+ * Which tool owns a workspace, preferring what provisioning recorded and
+ * falling back to what its checkout looks like.
+ *
+ * The recorded value is missing for environments provisioned before bb knew
+ * about jj; those rows are backfilled the next time the daemon refreshes
+ * workspace metadata, which can be a while. A jj checkout is recognizable in
+ * the meantime: git reports it detached, and bb reports the jj bookmark on it.
+ */
+export function resolveWorkspaceVcs(args: {
+ vcs: WorkspaceVcs | null | undefined;
+ checkout?: GitCheckoutRef | null;
+}): WorkspaceVcs | null {
+ if (args.vcs) {
+ return args.vcs;
+ }
+ if (args.checkout?.kind === "detached" && args.checkout.jj) {
+ return "jj";
+ }
+ return args.vcs ?? null;
+}
+
+/**
+ * What to call a bb-managed checkout in text a person reads.
+ *
+ * git calls it a worktree and jj calls it a workspace, and users of each expect
+ * their own word — so every user-facing string that names the thing goes
+ * through here rather than hardcoding one of them.
+ */
+export function managedCheckoutNoun(
+ vcs: WorkspaceVcs | null | undefined,
+ options: { capitalized?: boolean; plural?: boolean } = {},
+): string {
+ const noun = vcs === "jj" ? "workspace" : "worktree";
+ const withNumber = options.plural ? `${noun}s` : noun;
+ return options.capitalized
+ ? `${withNumber.charAt(0).toUpperCase()}${withNumber.slice(1)}`
+ : withNumber;
+}
+
/**
* Properties discovered about a workspace during provisioning.
* Used by the provision command result and to populate the environment record.
@@ -61,6 +112,7 @@ export const discoveredWorkspacePropertiesSchema = z.object({
path: z.string().min(1),
isGitRepo: z.boolean(),
isWorktree: z.boolean(),
+ vcs: workspaceVcsSchema,
branchName: z.string().nullable(),
defaultBranch: z.string().nullable(),
});
@@ -77,6 +129,9 @@ export const environmentSchema = z.object({
managed: z.boolean(),
isGitRepo: z.boolean(),
isWorktree: z.boolean(),
+ // Null for environments provisioned before bb knew about jj, and for ones
+ // whose workspace has not been discovered yet. Both read as git.
+ vcs: workspaceVcsSchema.nullable(),
workspaceProvisionType: workspaceProvisionTypeSchema,
branchName: z.string().nullable(),
baseBranch: z.string().nullable(),
diff --git a/packages/domain/src/git-checkout.ts b/packages/domain/src/git-checkout.ts
index fc889b9bc4..b1f6b5e4e5 100644
--- a/packages/domain/src/git-checkout.ts
+++ b/packages/domain/src/git-checkout.ts
@@ -54,6 +54,16 @@ export const gitCheckoutRefSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("detached"),
headSha: z.string().min(1).nullable(),
+ // Present iff the workspace is a colocated Jujutsu workspace (a `.jj`
+ // directory beside `.git`), where jj pins git HEAD to the working-copy
+ // parent and "detached" is the normal state. `bookmark` is the
+ // lexicographically first `refs/heads` ref pointing at HEAD (jj exports
+ // bookmarks as git branches), or null when no bookmark points there.
+ jj: z
+ .object({
+ bookmark: z.string().min(1).nullable(),
+ })
+ .optional(),
}),
z.object({
kind: z.literal("unborn"),
diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts
index 199eeba1f4..9674a89314 100644
--- a/packages/domain/src/thread.ts
+++ b/packages/domain/src/thread.ts
@@ -1,5 +1,8 @@
import { z } from "zod";
-import { environmentWorkspaceDisplayKindSchema } from "./environment.js";
+import {
+ environmentWorkspaceDisplayKindSchema,
+ workspaceVcsSchema,
+} from "./environment.js";
import { gitCheckoutRefSchema } from "./git-checkout.js";
import {
promptInputSchema,
@@ -400,5 +403,8 @@ export const threadListEntrySchema = threadWithRuntimeSchema.extend({
environmentName: z.string().nullable(),
environmentBranchName: z.string().nullable(),
environmentWorkspaceDisplayKind: environmentWorkspaceDisplayKindSchema,
+ // Names the checkout in the sidebar: jj environments are workspaces, git
+ // ones worktrees. Null for environments with no workspace discovered yet.
+ environmentVcs: workspaceVcsSchema.nullable(),
});
export type ThreadListEntry = z.infer;
diff --git a/packages/domain/test/environment.test.ts b/packages/domain/test/environment.test.ts
index d5cfbcbd32..8c350d4eba 100644
--- a/packages/domain/test/environment.test.ts
+++ b/packages/domain/test/environment.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ managedCheckoutNoun,
resolveEnvironmentMergeBaseBranch,
resolveEnvironmentWorkspaceDisplayKind,
} from "../src/environment.js";
@@ -48,3 +49,20 @@ describe("resolveEnvironmentWorkspaceDisplayKind", () => {
).toBe("other");
});
});
+
+describe("managedCheckoutNoun", () => {
+ it("names the checkout after the tool that owns it", () => {
+ expect(managedCheckoutNoun("jj")).toBe("workspace");
+ expect(managedCheckoutNoun("git")).toBe("worktree");
+ // Environments provisioned before bb knew about jj read as git.
+ expect(managedCheckoutNoun(null)).toBe("worktree");
+ });
+
+ it("capitalizes and pluralizes for the surfaces that need it", () => {
+ expect(managedCheckoutNoun("jj", { capitalized: true })).toBe("Workspace");
+ expect(managedCheckoutNoun("jj", { plural: true })).toBe("workspaces");
+ expect(managedCheckoutNoun("git", { capitalized: true, plural: true })).toBe(
+ "Worktrees",
+ );
+ });
+});
diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts
index c398b5d911..7fe81efd10 100644
--- a/packages/host-daemon-contract/src/protocol.ts
+++ b/packages/host-daemon-contract/src/protocol.ts
@@ -1,3 +1,12 @@
+// Version 172 supports Jujutsu sources. Managed checkouts on a colocated jj
+// repo are provisioned as jj workspaces rather than git worktrees, the
+// `detached` checkout variant can carry a `jj` object naming the bookmark at
+// HEAD, `workspace.commit` refuses to run in a jj main workspace with a typed
+// `jj_workspace` error the server maps to 409, and discovered workspace
+// properties carry a required `vcs` field ("git" or "jj") that the server
+// persists and names managed checkouts after. An older daemon omits `vcs`
+// entirely and reports no jj checkouts, so a mixed pairing fails validation.
+//
// Version 164 stops the server accepting any interaction lifecycle record
// from a daemon event batch. `system/interaction/lifecycle` and the legacy
// `system/permissionGrant/lifecycle` / `system/userQuestion/lifecycle` are
@@ -337,7 +346,7 @@
//
// The version mismatch is what triggers the enrolled daemon's automatic update
// instead of an `invalid-message` reconnect loop.
-export const HOST_DAEMON_PROTOCOL_VERSION = 171 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 172 as const;
/**
* Absolute ceiling for any executable artifact delivered to a host daemon —
diff --git a/packages/host-watcher/src/workspace-status-watcher.ts b/packages/host-watcher/src/workspace-status-watcher.ts
index 65fb134038..504844fdeb 100644
--- a/packages/host-watcher/src/workspace-status-watcher.ts
+++ b/packages/host-watcher/src/workspace-status-watcher.ts
@@ -31,8 +31,11 @@ const WORKSPACE_STATUS_WATCH_MAX_RETRY_DELAY_MS = 30_000;
// (resetting the count) when the watch set changes.
const WORKSPACE_STATUS_WATCH_MAX_SETUP_RETRY_ATTEMPTS = 10;
// Plain entries are paths relative to the watch root: `.git` only excludes
-// `/.git`, the workspace's own repository.
-const WORKSPACE_ROOT_ALWAYS_IGNORED_PATHS = [".git"];
+// `/.git`, the workspace's own repository. `.jj` is the same thing for a
+// Jujutsu workspace, and must be ignored for a second reason: reading a jj
+// workspace snapshots it, which rewrites the working-copy state under `.jj`.
+// Watching it would make every status read schedule the next one.
+const WORKSPACE_ROOT_ALWAYS_IGNORED_PATHS = [".git", ".jj"];
// Glob entries are matched against the root-relative path. On Linux parcel
// tests every directory during its crawl and a match skips the whole subtree,
// so no inotify watch is created below it. On macOS and Windows parcel tests
@@ -46,6 +49,7 @@ const WORKSPACE_ROOT_ALWAYS_IGNORED_PATHS = [".git"];
// so a plain directory can still be promoted after `git init`.
const WORKSPACE_ROOT_ALWAYS_IGNORED_GLOBS = [
"*/**/.git/**",
+ "*/**/.jj/**",
"**/node_modules/**",
"**/.cache/**",
"**/__pycache__/**",
diff --git a/packages/host-watcher/test/watch-specs-jj.test.ts b/packages/host-watcher/test/watch-specs-jj.test.ts
new file mode 100644
index 0000000000..ba691a4601
--- /dev/null
+++ b/packages/host-watcher/test/watch-specs-jj.test.ts
@@ -0,0 +1,80 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ collectWorkspaceStatusChanges,
+ resolveMetadataWatchSpecs,
+} from "../src/watch-specs.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ // The watch specs resolve symlinks (macOS /tmp -> /private/tmp), so hand
+ // tests the real path to make comparisons stable.
+ return fs.realpath(dir);
+}
+
+async function runJj(args: string[], cwd: string): Promise {
+ await execFileAsync("jj", args, { cwd });
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) =>
+ fs.rm(dir, { recursive: true, force: true }),
+ ),
+ );
+});
+
+describe.skipIf(!jjAvailable)("watch specs for colocated jj repos", () => {
+ it("watches the real .git dir and classifies bookmark exports as shared ref changes", async () => {
+ const repoPath = await makeTempDir("bb-jj-watch-repo-");
+ await runJj(["git", "init", "--colocate"], repoPath);
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], repoPath);
+ await runJj(
+ ["config", "set", "--repo", "user.email", "bb@example.com"],
+ repoPath,
+ );
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], repoPath);
+
+ const specs = await resolveMetadataWatchSpecs(repoPath);
+ expect(specs).not.toBeNull();
+ const gitDirSpec = specs?.find((spec) => spec.kind === "git-dir");
+ expect(gitDirSpec).toMatchObject({
+ rootPath: path.join(repoPath, ".git"),
+ includeSharedGitRefs: true,
+ });
+
+ // jj exports a moved bookmark by writing the branch ref into .git; that
+ // path must classify as a shared-git-refs change so server caches
+ // invalidate on bookmark moves.
+ await runJj(["bookmark", "create", "feature", "-r", "@-"], repoPath);
+ const exportedRefPath = path.join(
+ repoPath,
+ ".git",
+ "refs",
+ "heads",
+ "feature",
+ );
+ await fs.stat(exportedRefPath);
+
+ const change = collectWorkspaceStatusChanges({
+ events: [{ path: exportedRefPath, type: "update" }],
+ spec: gitDirSpec!,
+ });
+ expect(change?.changeKinds).toContain("shared-git-refs-changed");
+ });
+});
diff --git a/packages/host-watcher/test/watch-status.test.ts b/packages/host-watcher/test/watch-status.test.ts
index 7cadd9c9d4..726b362fab 100644
--- a/packages/host-watcher/test/watch-status.test.ts
+++ b/packages/host-watcher/test/watch-status.test.ts
@@ -733,7 +733,9 @@ describe.sequential("watchWorkspaceStatus", () => {
await ready;
expect(getWorkspaceRootSubscribeOptions()?.ignore).toEqual([
".git",
+ ".jj",
"*/**/.git/**",
+ "*/**/.jj/**",
"**/node_modules/**",
"**/.cache/**",
"**/__pycache__/**",
@@ -792,7 +794,9 @@ describe.sequential("watchWorkspaceStatus", () => {
expect(subscribedRoots).toEqual([normalizeWatchPath(repoPath)]);
expect(subscribedOptions[0]?.ignore).toEqual([
".git",
+ ".jj",
"*/**/.git/**",
+ "*/**/.jj/**",
"**/node_modules/**",
"**/.cache/**",
"**/__pycache__/**",
diff --git a/packages/host-workspace/src/git.ts b/packages/host-workspace/src/git.ts
index 0564e5d53e..2403d8cc94 100644
--- a/packages/host-workspace/src/git.ts
+++ b/packages/host-workspace/src/git.ts
@@ -744,6 +744,45 @@ export async function getCurrentBranch(
return branchName || undefined;
}
+/**
+ * A colocated Jujutsu workspace has a `.jj` directory beside the git
+ * checkout. jj keeps git HEAD detached at the working-copy parent there, so
+ * callers use this to tell "jj-managed detached" apart from a plain detached
+ * HEAD. A bb-managed worktree of a jj repo has no `.jj` and reads as plain
+ * git; a jj secondary workspace (`jj workspace add`) has no `.git` at all and
+ * never reaches this check.
+ */
+export async function detectJjColocatedWorkspace(cwd: string): Promise {
+ try {
+ const stats = await fs.lstat(path.join(cwd, ".jj"));
+ return stats.isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+async function listBranchesPointingAtHead(
+ cwd: string,
+ options: GitTimeoutOptions = {},
+): Promise {
+ const result = await runGit(
+ [
+ "for-each-ref",
+ "--points-at",
+ "HEAD",
+ "--format=%(refname:short)",
+ "refs/heads",
+ ],
+ { cwd, allowFailure: true, timeoutMs: options.timeoutMs },
+ );
+ if (result.exitCode !== 0) {
+ return [];
+ }
+ // for-each-ref output is sorted by refname, so the first entry is the
+ // deterministic lexicographic pick.
+ return trimOutput(result.stdout).split("\n").filter(Boolean);
+}
+
export async function getCheckoutRef(
cwd: string,
options: GitTimeoutOptions = {},
@@ -770,6 +809,10 @@ export async function getCheckoutRef(
}
if (headSha !== null) {
+ if (await detectJjColocatedWorkspace(cwd)) {
+ const branches = await listBranchesPointingAtHead(cwd, options);
+ return { kind: "detached", headSha, jj: { bookmark: branches[0] ?? null } };
+ }
return { kind: "detached", headSha };
}
diff --git a/packages/host-workspace/src/jj-workspace.ts b/packages/host-workspace/src/jj-workspace.ts
new file mode 100644
index 0000000000..d86e7e15bf
--- /dev/null
+++ b/packages/host-workspace/src/jj-workspace.ts
@@ -0,0 +1,195 @@
+import {
+ WorkspaceError,
+ runGit,
+ type GitProcessOptions,
+} from "./git.js";
+import {
+ readJjWorkingCopyCommits,
+ runJj,
+ syncShadowGitCheckout,
+ withJjRepoLock,
+ type JjWorkspaceLayout,
+} from "./jj.js";
+import {
+ Workspace,
+ type CommitOptions,
+ type CommitResult,
+ type DiffFilesArgs,
+ type DiffFilesResult,
+ type DiffOptions,
+ type DiffPatchArgs,
+ type DiffPatchEntry,
+ type DiffResult,
+ type StatusOptions,
+} from "./workspace.js";
+import type { WorkspaceStatus } from "@bb/domain";
+
+/**
+ * A bb-managed Jujutsu workspace (`jj workspace add`).
+ *
+ * jj owns the working copy: it snapshots edits into `@` and commits move `@-`.
+ * bb reads the workspace through a shadow git worktree registration kept at
+ * `@-` (see `attachShadowGitCheckout`), so every git-based read inherited from
+ * {@link Workspace} sees exactly what jj sees — uncommitted edits are the
+ * difference between `@-` and the files on disk. Only the mutations differ:
+ * they go through jj, because a git commit here would strand jj's previous
+ * working-copy change as an anonymous head.
+ *
+ * Reads therefore sync the shadow checkout first. The sync also performs the jj
+ * snapshot, so it is what makes on-disk edits visible in the first place.
+ */
+export class JjWorkspace extends Workspace {
+ readonly layout: JjWorkspaceLayout;
+ /** jj bookmark this workspace's committed work is published under. */
+ readonly bookmark: string;
+ /** Login-shell PATH so the daemon can find the jj binary. */
+ private readonly jjProcessOptions: GitProcessOptions;
+
+ constructor(args: {
+ path: string;
+ layout: JjWorkspaceLayout;
+ bookmark: string;
+ options?: GitProcessOptions;
+ }) {
+ super(args.path, args.options ?? {});
+ this.layout = args.layout;
+ this.bookmark = args.bookmark;
+ this.jjProcessOptions = args.options ?? {};
+ }
+
+ private async sync(): Promise {
+ await syncShadowGitCheckout(this.path, this.jjProcessOptions);
+ }
+
+ override async getStatus(options: StatusOptions = {}): Promise {
+ await this.sync();
+ return super.getStatus(options);
+ }
+
+ override async getDiff(options: DiffOptions = {}): Promise {
+ await this.sync();
+ return super.getDiff(options);
+ }
+
+ override async diffFiles(args: DiffFilesArgs): Promise {
+ await this.sync();
+ return super.diffFiles(args);
+ }
+
+ override async diffPatch(args: DiffPatchArgs): Promise {
+ await this.sync();
+ return super.diffPatch(args);
+ }
+
+ override async getLocalStateFingerprint(): Promise {
+ await this.sync();
+ return super.getLocalStateFingerprint();
+ }
+
+ override async getHeadSha(): Promise {
+ await this.sync();
+ return super.getHeadSha();
+ }
+
+ override async listFiles(): Promise {
+ await this.sync();
+ return super.listFiles();
+ }
+
+ /**
+ * Commits the working copy with jj, then moves this workspace's bookmark to
+ * the new commit and exports it so the source repository's git refs — and
+ * everything bb reads from them — see the work.
+ */
+ override async commit(options: CommitOptions): Promise {
+ return withJjRepoLock(this.layout, async () => {
+ const before = await readJjWorkingCopyCommits(this.path, this.jjProcessOptions);
+ const isEmpty = await this.isWorkingCopyEmpty();
+ if (isEmpty) {
+ throw new WorkspaceError("no_changes", "No changes to commit");
+ }
+
+ // jj has no commit hooks, so noVerify has nothing to switch off here.
+ await runJj(["commit", "-m", options.message], { cwd: this.path, ...this.jjProcessOptions });
+ const after = await readJjWorkingCopyCommits(this.path, this.jjProcessOptions);
+ await runJj(["bookmark", "set", this.bookmark, "-r", after.parent], {
+ cwd: this.path,
+ ...this.jjProcessOptions,
+ });
+ await this.exportBookmark(after.parent);
+ await this.sync();
+
+ if (after.parent === before.parent) {
+ throw new WorkspaceError(
+ "jj_command_failed",
+ "jj commit did not advance the working-copy parent",
+ );
+ }
+ const commitSubject = (
+ await runGit(["log", "-1", "--pretty=%s", after.parent], {
+ cwd: this.path,
+ })
+ ).stdout.trim();
+ return { commitSha: after.parent, commitSubject };
+ });
+ }
+
+ override async reset(): Promise {
+ await withJjRepoLock(this.layout, async () => {
+ await runJj(["restore"], { cwd: this.path, ...this.jjProcessOptions });
+ await this.sync();
+ });
+ }
+
+ /**
+ * The committed work lives at `@-`, which is where the bookmark points once
+ * bb has committed. Agents committing with jj directly move `@-` without
+ * touching the bookmark, so the commit itself is the honest source.
+ */
+ protected override async resolveSquashMergeSource(): Promise {
+ await this.sync();
+ const { parent } = await readJjWorkingCopyCommits(this.path, this.jjProcessOptions);
+ return parent;
+ }
+
+ private async isWorkingCopyEmpty(): Promise {
+ const result = await runJj(
+ ["log", "--no-graph", "-r", "@", "-T", 'if(empty, "empty", "changed")'],
+ { cwd: this.path, ...this.jjProcessOptions },
+ );
+ return result.stdout.trim() === "empty";
+ }
+
+ /**
+ * Exports jj bookmarks to the colocated git repository and verifies the ref
+ * landed. `jj git export` reports success even when it refuses a bookmark
+ * whose git ref moved underneath it, so a mismatch is retried once after
+ * importing the git side.
+ */
+ private async exportBookmark(expectedSha: string): Promise {
+ await runJj(["git", "export"], { cwd: this.path, ...this.jjProcessOptions });
+ if (await this.bookmarkRefMatches(expectedSha)) {
+ return;
+ }
+
+ await runJj(["git", "import"], { cwd: this.path, ...this.jjProcessOptions });
+ await runJj(["bookmark", "set", this.bookmark, "-r", expectedSha], {
+ cwd: this.path,
+ });
+ await runJj(["git", "export"], { cwd: this.path, ...this.jjProcessOptions });
+ if (!(await this.bookmarkRefMatches(expectedSha))) {
+ throw new WorkspaceError(
+ "jj_export_failed",
+ `Could not export bookmark ${this.bookmark} to git`,
+ );
+ }
+ }
+
+ private async bookmarkRefMatches(expectedSha: string): Promise {
+ const ref = await runGit(
+ ["rev-parse", "--verify", `refs/heads/${this.bookmark}`],
+ { cwd: this.path, allowFailure: true },
+ );
+ return ref.exitCode === 0 && ref.stdout.trim() === expectedSha;
+ }
+}
diff --git a/packages/host-workspace/src/jj.ts b/packages/host-workspace/src/jj.ts
new file mode 100644
index 0000000000..9258d68765
--- /dev/null
+++ b/packages/host-workspace/src/jj.ts
@@ -0,0 +1,335 @@
+import { execFile, type ExecFileException } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils";
+import { WorkspaceError, runGit, type GitProcessOptions } from "./git.js";
+import {
+ withProcessLocalQueuedLocks,
+ type ProcessLocalQueuedLockWork,
+} from "./process-local-queued-lock.js";
+
+const execFileAsync = promisify(execFile);
+const DEFAULT_BUFFER_BYTES = 16 * 1024 * 1024;
+
+export interface RunJjOptions extends GitProcessOptions {
+ cwd: string;
+ timeoutMs?: number;
+ allowFailure?: boolean;
+ signal?: AbortSignal;
+}
+
+export interface JjCommandResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+}
+
+/**
+ * Runs jj with a non-interactive, machine-readable configuration. `--no-pager`
+ * and `--color=never` keep stdout parseable; `--quiet` is deliberately not
+ * passed because several commands report their outcome on stdout.
+ *
+ * Note that most jj commands snapshot the working copy as a side effect. Pass
+ * `--ignore-working-copy` in the caller when a read must not write.
+ */
+export async function runJj(
+ args: string[],
+ options: RunJjOptions,
+): Promise {
+ const fullArgs = ["--no-pager", "--color=never", ...args];
+ try {
+ const result = await execFileAsync("jj", fullArgs, {
+ cwd: options.cwd,
+ encoding: "utf8",
+ env: sanitizeInheritedChildProcessEnv({
+ env: process.env,
+ ...(options.shellPath !== undefined
+ ? { shellPath: options.shellPath }
+ : {}),
+ }),
+ maxBuffer: DEFAULT_BUFFER_BYTES,
+ signal: options.signal,
+ timeout: options.timeoutMs,
+ });
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
+ } catch (error) {
+ const execError =
+ error instanceof Error ? (error as ExecFileException) : undefined;
+ if (options.signal?.aborted) {
+ throw new WorkspaceError(
+ "provision_cancelled",
+ `jj ${args.join(" ")} was cancelled`,
+ { cause: error },
+ );
+ }
+ if (
+ options.timeoutMs !== undefined &&
+ execError?.killed === true &&
+ execError.signal === "SIGTERM"
+ ) {
+ throw new WorkspaceError(
+ "jj_command_timeout",
+ `jj ${args.join(" ")} timed out after ${options.timeoutMs}ms`,
+ { cause: error },
+ );
+ }
+ if (options.allowFailure) {
+ return {
+ stdout: execError?.stdout ?? "",
+ stderr: execError?.stderr ?? "",
+ exitCode: typeof execError?.code === "number" ? execError.code : 1,
+ };
+ }
+
+ const stderr = (execError?.stderr ?? "").trim();
+ throw new WorkspaceError(
+ "jj_command_failed",
+ `jj ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`,
+ { cause: error },
+ );
+ }
+}
+
+export interface JjWorkspaceLayout {
+ /**
+ * `main` is the workspace the repo was initialized in. Colocated repos keep
+ * a real `.git` beside `.jj` there. `secondary` is a `jj workspace add`
+ * workspace: it has no `.git` at all, so every git command has to run in the
+ * source repo instead.
+ */
+ kind: "main" | "secondary";
+ /** Absolute path to the `.jj/repo` directory that backs this workspace. */
+ repoPath: string;
+ /** Absolute path to the repository root that owns `.jj/repo`. */
+ sourcePath: string;
+}
+
+/**
+ * Resolves the jj layout of a directory, or null when it is not inside a jj
+ * workspace root.
+ *
+ * A main workspace has `.jj/repo` as a directory. A secondary workspace has
+ * `.jj/repo` as a file holding a path to the main workspace's `.jj/repo`,
+ * which jj writes relative to the `.jj` directory that contains it.
+ */
+export async function resolveJjWorkspaceLayout(
+ cwd: string,
+): Promise {
+ const jjDir = path.join(cwd, ".jj");
+ const repoEntry = path.join(jjDir, "repo");
+ let stats;
+ try {
+ stats = await fs.lstat(repoEntry);
+ } catch {
+ return null;
+ }
+
+ if (stats.isDirectory()) {
+ return { kind: "main", repoPath: repoEntry, sourcePath: path.resolve(cwd) };
+ }
+ if (!stats.isFile()) {
+ return null;
+ }
+
+ const pointer = (await fs.readFile(repoEntry, "utf8")).trim();
+ if (!pointer) {
+ return null;
+ }
+ const repoPath = path.resolve(jjDir, pointer);
+ return {
+ kind: "secondary",
+ repoPath,
+ // /.jj/repo ->
+ sourcePath: path.resolve(repoPath, "..", ".."),
+ };
+}
+
+/**
+ * True for a source repository bb can provision jj workspaces from: a jj main
+ * workspace colocated with a real git repository. The colocated `.git` is what
+ * lets bb keep reading diffs, merge bases and blobs with git.
+ */
+export async function detectColocatedJjSource(cwd: string): Promise {
+ const layout = await resolveJjWorkspaceLayout(cwd);
+ if (layout?.kind !== "main") {
+ return false;
+ }
+ try {
+ return (await fs.lstat(path.join(cwd, ".git"))).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Commit ids of the working-copy commit and its first parent.
+ *
+ * Reading these snapshots the working copy, which is what makes the edits on
+ * disk visible to everything downstream. `@` is the working copy, `@-` is what
+ * git calls HEAD. A merge working copy has several parents; bb follows the
+ * first one, matching how git reports a merge checkout.
+ */
+export interface JjWorkingCopyCommits {
+ at: string;
+ parent: string;
+}
+
+export async function readJjWorkingCopyCommits(
+ cwd: string,
+ options: GitProcessOptions & { timeoutMs?: number; signal?: AbortSignal } = {},
+): Promise {
+ const result = await runJj(
+ [
+ "log",
+ "--no-graph",
+ "-r",
+ "@",
+ "-T",
+ 'commit_id ++ "\\n" ++ parents.map(|parent| parent.commit_id()).join(" ") ++ "\\n"',
+ ],
+ { cwd, timeoutMs: options.timeoutMs, signal: options.signal },
+ );
+ const [at = "", parents = ""] = result.stdout.split("\n");
+ const parent = parents.trim().split(" ")[0] ?? "";
+ if (!at || !parent) {
+ throw new WorkspaceError(
+ "jj_command_failed",
+ "jj log did not report the working-copy commit and its parent",
+ );
+ }
+ return { at, parent };
+}
+
+/**
+ * Points the workspace's shadow git checkout at jj's `@-` and rebuilds its
+ * index from that commit.
+ *
+ * bb provisions a jj workspace with a git worktree registration beside it (see
+ * `attachShadowGitCheckout`) so every git-based read keeps working. jj knows
+ * nothing about that registration, so whenever jj moves `@-` — bb's own commit,
+ * or an agent running jj directly — git's HEAD and index have to be pulled
+ * along before anything reads them. `reset` moves both without touching a file
+ * in the working tree, so jj stays the only writer of the checkout itself.
+ */
+export async function syncShadowGitCheckout(
+ workspacePath: string,
+ options: GitProcessOptions & { timeoutMs?: number; signal?: AbortSignal } = {},
+): Promise {
+ const { parent } = await readJjWorkingCopyCommits(workspacePath, options);
+ const head = await runGit(["rev-parse", "HEAD"], {
+ cwd: workspacePath,
+ allowFailure: true,
+ timeoutMs: options.timeoutMs,
+ signal: options.signal,
+ });
+ if (head.exitCode === 0 && head.stdout.trim() === parent) {
+ return;
+ }
+ await runGit(["reset", "-q", parent], {
+ cwd: workspacePath,
+ timeoutMs: options.timeoutMs,
+ signal: options.signal,
+ });
+}
+
+/**
+ * Name jj knows a workspace directory by, or null when the repository has no
+ * workspace rooted there.
+ *
+ * Workspaces are matched on the root path jj records rather than on a name bb
+ * remembers, so this also resolves after a restart, and for workspaces bb did
+ * not create.
+ */
+export async function readJjWorkspaceName(
+ sourcePath: string,
+ workspacePath: string,
+ options: GitProcessOptions = {},
+): Promise {
+ const listed = await runJj(
+ ["workspace", "list", "-T", 'name ++ "\\t" ++ if(root, root, "") ++ "\\n"'],
+ { cwd: sourcePath, allowFailure: true, ...options },
+ );
+ if (listed.exitCode !== 0) {
+ return null;
+ }
+
+ const target = path.resolve(workspacePath);
+ for (const line of listed.stdout.split("\n")) {
+ const [name = "", root = ""] = line.split("\t");
+ if (name && root && path.resolve(root) === target) {
+ return name;
+ }
+ }
+ return null;
+}
+
+/**
+ * Registers an existing jj workspace as a git worktree of its source
+ * repository, so bb reads it with the same git commands it uses everywhere
+ * else.
+ *
+ * `git worktree add` insists on an empty directory and jj has already filled
+ * this one, so the registration is created next to it and then moved in:
+ * `worktree repair` rewrites both ends of the pointer pair afterwards. The
+ * checkout ends up detached at `@-`, which is the same shape jj leaves behind
+ * in a colocated main workspace.
+ */
+export async function attachShadowGitCheckout(
+ args: GitProcessOptions & {
+ sourcePath: string;
+ workspacePath: string;
+ timeoutMs?: number;
+ signal?: AbortSignal;
+ },
+): Promise {
+ const { sourcePath, workspacePath } = args;
+ const gitOptions = {
+ timeoutMs: args.timeoutMs,
+ signal: args.signal,
+ ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}),
+ };
+ // jj writes this for colocated main workspaces but not for the ones it adds;
+ // without it git reports the whole .jj directory as untracked.
+ await fs.writeFile(path.join(workspacePath, ".jj", ".gitignore"), "/*\n", "utf8");
+
+ const stagingParent = await fs.mkdtemp(path.join(os.tmpdir(), "bb-jj-git-"));
+ // git names the registration after the destination's basename; keeping the
+ // same basename here keeps `git worktree list` readable.
+ const stagingPath = path.join(stagingParent, path.basename(workspacePath));
+ try {
+ const { parent } = await readJjWorkingCopyCommits(workspacePath, gitOptions);
+ await runGit(
+ ["worktree", "add", "--detach", "--no-checkout", stagingPath, parent],
+ { cwd: sourcePath, ...gitOptions },
+ );
+ await fs.rename(
+ path.join(stagingPath, ".git"),
+ path.join(workspacePath, ".git"),
+ );
+ await runGit(["worktree", "repair"], { cwd: workspacePath, ...gitOptions });
+ // --no-checkout left the index empty; reset fills it from @- without
+ // touching the files jj checked out.
+ await runGit(["reset", "-q", parent], { cwd: workspacePath, ...gitOptions });
+ } finally {
+ await fs.rm(stagingParent, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Serializes jj mutations against one repository. jj takes its own lock on the
+ * repo, so this only avoids pile-ups and keeps bb's own sequences (commit ->
+ * bookmark set -> git export) atomic with respect to each other.
+ */
+export async function withJjRepoLock(
+ layout: JjWorkspaceLayout,
+ work: ProcessLocalQueuedLockWork,
+ signal?: AbortSignal,
+): Promise {
+ return withProcessLocalQueuedLocks({
+ locks: [{ key: `jj-repo:${layout.repoPath}` }],
+ signal,
+ work,
+ });
+}
diff --git a/packages/host-workspace/src/provision.ts b/packages/host-workspace/src/provision.ts
index a90931a71d..3ff89c15ab 100644
--- a/packages/host-workspace/src/provision.ts
+++ b/packages/host-workspace/src/provision.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import type {
ProvisioningTranscriptEntry,
WorkspaceStatus,
+ WorkspaceVcs,
} from "@bb/domain";
import type {
CommitOptions,
@@ -19,6 +20,8 @@ import type {
SquashMergeResult,
} from "./workspace.js";
import { Workspace } from "./workspace.js";
+import { JjWorkspace } from "./jj-workspace.js";
+import { readJjWorkspaceName, resolveJjWorkspaceLayout } from "./jj.js";
import type {
GitHostCliOptions,
GitHostPullRequestLookup,
@@ -151,6 +154,12 @@ export interface HostWorkspace {
readonly isGitRepo: boolean;
/** Whether this is a git worktree (vs. a standalone repo) */
readonly isWorktree: boolean;
+ /**
+ * Which tool owns the working copy. "jj" for a Jujutsu workspace, which bb
+ * reads through a git checkout kept alongside jj — so `isGitRepo` and
+ * `isWorktree` are true there as well.
+ */
+ readonly vcs: WorkspaceVcs;
// Git queries
getDefaultBranch(): Promise;
@@ -185,6 +194,32 @@ export interface HostWorkspace {
// Detect whether a path is a git worktree
// ---------------------------------------------------------------------------
+/**
+ * Builds the workspace implementation for a managed checkout: a
+ * {@link JjWorkspace} when jj owns the working copy there, a plain
+ * {@link Workspace} otherwise.
+ *
+ * The jj bookmark carrying the checkout's committed work is named after the
+ * workspace itself, which bb creates from the branch name. Reading it back from
+ * jj rather than remembering it means a reconnect after a restart resolves the
+ * same bookmark.
+ */
+async function createManagedWorkspace(
+ wsPath: string,
+ options: GitProcessOptions,
+): Promise {
+ const layout = await resolveJjWorkspaceLayout(wsPath);
+ if (layout?.kind !== "secondary") {
+ return new Workspace(wsPath, options);
+ }
+
+ const bookmark = await readJjWorkspaceName(layout.sourcePath, wsPath, options);
+ if (!bookmark) {
+ return new Workspace(wsPath, options);
+ }
+ return new JjWorkspace({ path: wsPath, layout, bookmark, options });
+}
+
async function detectWorktree(
cwd: string,
options: GitProcessOptions,
@@ -211,6 +246,7 @@ class ProvisionedHostWorkspace implements HostWorkspace {
readonly managed: boolean;
readonly isGitRepo: boolean;
readonly isWorktree: boolean;
+ readonly vcs: WorkspaceVcs;
private readonly ws: Workspace;
private readonly gitProcessOptions: GitProcessOptions;
@@ -222,6 +258,7 @@ class ProvisionedHostWorkspace implements HostWorkspace {
isGitRepo: boolean;
isWorktree: boolean;
shellPath?: string;
+ workspace?: Workspace;
destroyFn: () => Promise;
}) {
this.path = opts.path;
@@ -231,7 +268,9 @@ class ProvisionedHostWorkspace implements HostWorkspace {
this.gitProcessOptions = {
...(opts.shellPath !== undefined ? { shellPath: opts.shellPath } : {}),
};
- this.ws = new Workspace(opts.path, this.gitProcessOptions);
+ this.ws =
+ opts.workspace ?? new Workspace(opts.path, this.gitProcessOptions);
+ this.vcs = this.ws instanceof JjWorkspace ? "jj" : "git";
this.destroyFn = opts.destroyFn;
}
@@ -736,6 +775,9 @@ async function provisionWorktree(
isGitRepo: true,
isWorktree: true,
shellPath: opts.shellPath,
+ workspace: await createManagedWorkspace(wsPath, {
+ ...(opts.shellPath !== undefined ? { shellPath: opts.shellPath } : {}),
+ }),
destroyFn: () =>
removeWorktree({
path: wsPath,
@@ -814,6 +856,7 @@ async function reconnectManaged(
isGitRepo,
isWorktree,
shellPath,
+ workspace: await createManagedWorkspace(wsPath, gitProcessOptions),
destroyFn,
});
}
diff --git a/packages/host-workspace/src/provisioning.ts b/packages/host-workspace/src/provisioning.ts
index 461c7b10e3..390e33a80d 100644
--- a/packages/host-workspace/src/provisioning.ts
+++ b/packages/host-workspace/src/provisioning.ts
@@ -16,6 +16,7 @@ import {
import { Workspace } from "./workspace.js";
import { tryWithCheckoutMutationLock } from "./checkout-mutation-lock.js";
import {
+ getGitCommonDir,
pathExists,
readDefaultBranch,
readGitRepositoryState,
@@ -23,6 +24,13 @@ import {
WorkspaceError,
type GitCommandResult,
} from "./git.js";
+import {
+ attachShadowGitCheckout,
+ detectColocatedJjSource,
+ readJjWorkspaceName,
+ resolveJjWorkspaceLayout,
+ runJj,
+} from "./jj.js";
import {
runGitWithWorktreeMetadataLock,
withWorktreeMetadataLock,
@@ -161,9 +169,28 @@ async function ensureExistingWorkspaceMatches(
return false;
}
- const workspace = new Workspace(targetPath, {
+ const gitProcessOptions = {
...(shellPath !== undefined ? { shellPath } : {}),
- });
+ };
+ const jjLayout = await resolveJjWorkspaceLayout(targetPath);
+ if (jjLayout?.kind === "secondary") {
+ // A jj workspace has no git branch to compare; jj names the workspace
+ // after the branch bb asked for, so that name is the identity check.
+ const name = await readJjWorkspaceName(
+ jjLayout.sourcePath,
+ targetPath,
+ gitProcessOptions,
+ );
+ if (name !== branchName) {
+ throw new WorkspaceError(
+ "path_exists",
+ `Target path exists as a different jj workspace: ${targetPath}`,
+ );
+ }
+ return true;
+ }
+
+ const workspace = new Workspace(targetPath, gitProcessOptions);
if (!(await workspace.isGitRepo)) {
throw new WorkspaceError(
"path_exists",
@@ -327,6 +354,93 @@ async function fetchRemoteBaseBranch(args: {
}
}
+/**
+ * Rewrites a base branch as something jj can resolve.
+ *
+ * Bases arrive in git's spelling, where a remote-tracking branch is
+ * `origin/main`. jj has no such ref: the same commit is the remote bookmark
+ * `main@origin`. Local branches are spelled the same in both, so they pass
+ * through — including ones whose name contains a slash.
+ */
+async function toJjBaseRevset(
+ sourcePath: string,
+ baseBranch: string,
+ shellPath: string | undefined,
+ signal: AbortSignal | undefined,
+): Promise {
+ const remoteBase = await resolveRemoteBaseBranch(
+ sourcePath,
+ baseBranch,
+ shellPath,
+ signal,
+ );
+ return remoteBase ? `${remoteBase.branch}@${remoteBase.remote}` : baseBranch;
+}
+
+/**
+ * Creates the managed checkout for a colocated Jujutsu source as a real jj
+ * workspace, so the thread's work is jj-native: it shows up in `jj log`, and
+ * jj's operation log can undo it.
+ *
+ * The workspace is named after the branch bb would otherwise have created, and
+ * a bookmark of that name is created on the base so committed work has
+ * somewhere to land. `attachShadowGitCheckout` then registers the workspace as
+ * a git worktree, which is what lets every git-based read keep working.
+ */
+async function createJjWorkspace(args: {
+ sourcePath: string;
+ targetPath: string;
+ branchName: string;
+ baseBranch: string;
+ shellPath?: string;
+ signal?: AbortSignal;
+}): Promise {
+ const jjOptions = {
+ ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}),
+ };
+ const baseRevset = await toJjBaseRevset(
+ args.sourcePath,
+ args.baseBranch,
+ args.shellPath,
+ args.signal,
+ );
+ const commonDir = await getGitCommonDir(args.sourcePath, jjOptions);
+ const result = await withWorktreeMetadataLock(
+ commonDir,
+ () =>
+ runJj(
+ [
+ "workspace",
+ "add",
+ "--name",
+ args.branchName,
+ args.targetPath,
+ "-r",
+ baseRevset,
+ ],
+ { cwd: args.sourcePath, signal: args.signal, ...jjOptions },
+ ),
+ args.signal,
+ );
+
+ await runJj(
+ ["bookmark", "set", args.branchName, "-r", baseRevset],
+ { cwd: args.sourcePath, signal: args.signal, ...jjOptions },
+ );
+ await runJj(["git", "export"], {
+ cwd: args.sourcePath,
+ signal: args.signal,
+ ...jjOptions,
+ });
+ await attachShadowGitCheckout({
+ sourcePath: args.sourcePath,
+ workspacePath: args.targetPath,
+ signal: args.signal,
+ ...jjOptions,
+ });
+ return result;
+}
+
export async function createWorktree(
args: CreateWorkspaceArgs,
): Promise<{ path: string }> {
@@ -385,6 +499,7 @@ export async function createWorktree(
signal: args.signal,
});
+ const usesJj = await detectColocatedJjSource(args.sourcePath);
const gitArgs = [
"worktree",
"add",
@@ -397,22 +512,31 @@ export async function createWorktree(
emitStep({
onProgress: args.onProgress,
key: "git-worktree-started",
- text: "Creating worktree",
+ text: usesJj ? "Creating jj workspace" : "Creating worktree",
status: "started",
startedAt: worktreeStartedAt,
});
let worktreeCreated = false;
try {
- const result = await runGitWithWorktreeMetadataLock(gitArgs, {
- cwd: args.sourcePath,
- ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}),
- signal: args.signal,
- });
+ const result = usesJj
+ ? await createJjWorkspace({
+ sourcePath: args.sourcePath,
+ targetPath: args.targetPath,
+ branchName: args.branchName,
+ baseBranch,
+ shellPath: args.shellPath,
+ signal: args.signal,
+ })
+ : await runGitWithWorktreeMetadataLock(gitArgs, {
+ cwd: args.sourcePath,
+ ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}),
+ signal: args.signal,
+ });
emitGitOutput(args.onProgress, "git-worktree", result);
emitStep({
onProgress: args.onProgress,
key: "git-worktree-completed",
- text: "Created worktree",
+ text: usesJj ? "Created jj workspace" : "Created worktree",
status: "completed",
startedAt: worktreeStartedAt,
metadata: { durationMs: Date.now() - worktreeStartedAt },
@@ -443,7 +567,7 @@ export async function createWorktree(
emitStep({
onProgress: args.onProgress,
key: "git-worktree-failed",
- text: "Worktree setup failed",
+ text: usesJj ? "jj workspace setup failed" : "Worktree setup failed",
status: "failed",
startedAt: worktreeStartedAt,
metadata: { durationMs: Date.now() - worktreeStartedAt },
@@ -733,6 +857,15 @@ export async function removeWorktree(args: RemoveWorktreeArgs): Promise {
return;
}
+ // Resolve the jj workspace before git tears the directory down. jj matches
+ // workspaces by a root path it has to resolve on disk, so the name is only
+ // discoverable while the directory still exists.
+ const jjLayout = await resolveJjWorkspaceLayout(workspacePath);
+ const jjWorkspaceName =
+ jjLayout?.kind === "secondary"
+ ? await readJjWorkspaceName(jjLayout.sourcePath, workspacePath)
+ : null;
+
const commonDirResult = await runGit(["rev-parse", "--git-common-dir"], {
cwd: workspacePath,
...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}),
@@ -774,6 +907,15 @@ export async function removeWorktree(args: RemoveWorktreeArgs): Promise {
);
}
+ if (jjLayout && jjWorkspaceName) {
+ // The registration outlives the directory unless it is forgotten
+ // explicitly. Best-effort, for the same reason git metadata cleanup is.
+ await runJj(["workspace", "forget", jjWorkspaceName], {
+ cwd: jjLayout.sourcePath,
+ allowFailure: true,
+ });
+ }
+
// Git metadata cleanup is best-effort because broken teardown states often
// leave a directory that no longer resolves as a worktree. The managed
// workspace directory itself is the authoritative cleanup target.
diff --git a/packages/host-workspace/src/workspace-write-roots.ts b/packages/host-workspace/src/workspace-write-roots.ts
index 84b650927f..74be38ceb6 100644
--- a/packages/host-workspace/src/workspace-write-roots.ts
+++ b/packages/host-workspace/src/workspace-write-roots.ts
@@ -4,6 +4,7 @@ import {
getGitCommonDir,
type GitProcessOptions,
} from "./git.js";
+import { resolveJjWorkspaceLayout } from "./jj.js";
function isSamePathOrNestedUnder(childPath: string, parentPath: string): boolean {
const relativePath = path.relative(parentPath, childPath);
@@ -46,9 +47,16 @@ export async function resolveAdditionalWorkspaceWriteRoots(
getAbsoluteGitDir(resolvedWorkspacePath, options),
getGitCommonDir(resolvedWorkspacePath, options),
]);
+ // A jj workspace keeps its operation log and repository state in the source
+ // repository, outside the workspace, so an agent running jj here writes there
+ // as well as into the shadow git checkout's own directories.
+ const jjLayout = await resolveJjWorkspaceLayout(resolvedWorkspacePath);
+ const jjRoots =
+ jjLayout?.kind === "secondary" ? [jjLayout.repoPath] : ([] as string[]);
const candidateRoots = dedupeResolvedPaths([
gitDir,
...buildCommonGitWriteRoots(commonGitDir),
+ ...jjRoots,
]);
return candidateRoots.filter(
diff --git a/packages/host-workspace/src/workspace.ts b/packages/host-workspace/src/workspace.ts
index 915308b3d9..3ff4a1df93 100644
--- a/packages/host-workspace/src/workspace.ts
+++ b/packages/host-workspace/src/workspace.ts
@@ -19,6 +19,7 @@ import {
import {
createTempDir,
detectGitRepo,
+ detectJjColocatedWorkspace,
ensureGitRepo,
getCheckoutRef,
getCurrentBranch,
@@ -1132,6 +1133,18 @@ export class Workspace {
async commit(options: CommitOptions): Promise {
await ensureGitRepo(this.path, this.gitProcessOptions);
+ // A git commit in a colocated Jujutsu workspace imports without conflict
+ // but strands the previous working-copy change as a visible anonymous
+ // head in `jj log` and moves no bookmark, so refuse it with a typed error
+ // instead. bb-managed worktrees of a jj repo have no `.jj` directory and
+ // commit normally.
+ if (await detectJjColocatedWorkspace(this.path)) {
+ throw new WorkspaceError(
+ "jj_workspace",
+ "This workspace is managed by jj; use jj to describe or commit the change",
+ );
+ }
+
return this.withMutation(async () => {
await this.runGit(["add", "-A"], { cwd: this.path });
// Detect "nothing to commit" deterministically inside the mutation lock,
@@ -1176,14 +1189,7 @@ export class Workspace {
): Promise {
await ensureGitRepo(this.path, this.gitProcessOptions);
- const sourceBranch = await this.currentBranch;
- if (!sourceBranch) {
- throw new WorkspaceError(
- "detached_head",
- "Cannot squash merge from a detached workspace",
- );
- }
-
+ const sourceBranch = await this.resolveSquashMergeSource();
const target = await this.resolveSquashMergeTarget(options.targetBranch);
const tempDir = await createTempDir("bb-squash-");
const tempDirPath = path.resolve(tempDir);
@@ -1197,9 +1203,14 @@ export class Workspace {
tempDir,
this.gitProcessOptions,
).withMutation(async () => {
- await this.runGit(["merge", "--squash", sourceBranch], {
- cwd: tempDir,
- });
+ // -c merge.ff=true: git refuses --squash outright when the user
+ // configured merge.ff=false, and this merge is bb's own, not theirs.
+ await this.runGit(
+ ["-c", "merge.ff=true", "merge", "--squash", sourceBranch],
+ {
+ cwd: tempDir,
+ },
+ );
// A squash of a branch with no committed work ahead of the target
// stages nothing; surface that as a typed no_changes condition the
// server maps to 409, not a generic git "nothing to commit" failure.
@@ -1261,6 +1272,23 @@ export class Workspace {
}
}
+ /**
+ * The revision whose work gets squashed into the target branch. A git
+ * checkout squashes its branch; a detached one has nothing to name, so the
+ * merge is refused. {@link JjWorkspace} overrides this because its work is
+ * named by a jj bookmark rather than a git branch.
+ */
+ protected async resolveSquashMergeSource(): Promise {
+ const sourceBranch = await this.currentBranch;
+ if (!sourceBranch) {
+ throw new WorkspaceError(
+ "detached_head",
+ "Cannot squash merge from a detached workspace",
+ );
+ }
+ return sourceBranch;
+ }
+
private async resolveSquashMergeTarget(
targetBranch: string,
): Promise {
diff --git a/packages/host-workspace/test/jj-colocated.test.ts b/packages/host-workspace/test/jj-colocated.test.ts
new file mode 100644
index 0000000000..cadc87535a
--- /dev/null
+++ b/packages/host-workspace/test/jj-colocated.test.ts
@@ -0,0 +1,213 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import { Workspace } from "../src/workspace.js";
+import { getCheckoutRef, runGit } from "../src/git.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+async function runJj(args: string[], cwd: string): Promise {
+ const result = await execFileAsync("jj", args, { cwd });
+ return result.stdout;
+}
+
+async function initColocatedJjRepo(): Promise {
+ const repoPath = await makeTempDir("bb-jj-colocated-repo-");
+ await runJj(["git", "init", "--colocate"], repoPath);
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], repoPath);
+ await runJj(
+ ["config", "set", "--repo", "user.email", "bb@example.com"],
+ repoPath,
+ );
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], repoPath);
+ return repoPath;
+}
+
+async function initPlainGitRepo(): Promise {
+ const repoPath = await makeTempDir("bb-jj-plain-git-repo-");
+ await runGit(["init", "-b", "main"], { cwd: repoPath });
+ await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath });
+ await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath });
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runGit(["add", "README.md"], { cwd: repoPath });
+ await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath });
+ return repoPath;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) =>
+ fs.rm(dir, { recursive: true, force: true }),
+ ),
+ );
+});
+
+describe.skipIf(!jjAvailable)("colocated jj workspaces", () => {
+ it("reports a detached checkout with the bookmark pointing at HEAD", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "feature", "-r", "@-"], repoPath);
+
+ const checkout = await getCheckoutRef(repoPath);
+ expect(checkout).toEqual({
+ kind: "detached",
+ headSha: expect.any(String),
+ jj: { bookmark: "feature" },
+ });
+ });
+
+ it("picks the lexicographically first bookmark when several point at HEAD", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "zebra", "-r", "@-"], repoPath);
+ await runJj(["bookmark", "create", "alpha", "-r", "@-"], repoPath);
+
+ const checkout = await getCheckoutRef(repoPath);
+ expect(checkout).toMatchObject({ jj: { bookmark: "alpha" } });
+ });
+
+ it("reports a null bookmark when no bookmark points at HEAD", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "old", "-r", "@-"], repoPath);
+ await fs.writeFile(path.join(repoPath, "later.txt"), "later\n", "utf8");
+ await runJj(["commit", "-m", "Second commit"], repoPath);
+
+ const checkout = await getCheckoutRef(repoPath);
+ expect(checkout).toMatchObject({
+ kind: "detached",
+ jj: { bookmark: null },
+ });
+ });
+
+ it("does not add the jj field in a plain git repo", async () => {
+ const repoPath = await initPlainGitRepo();
+ await runGit(["checkout", "--detach"], { cwd: repoPath });
+
+ const checkout = await getCheckoutRef(repoPath);
+ expect(checkout.kind).toBe("detached");
+ expect(checkout).not.toHaveProperty("jj");
+ });
+
+ it("treats a managed worktree of a jj repo as a plain git branch checkout", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "main", "-r", "@-"], repoPath);
+ const worktreeParent = await makeTempDir("bb-jj-worktree-parent-");
+ const worktreePath = path.join(worktreeParent, "feature");
+ await runGit(["worktree", "add", "-B", "bb/test", worktreePath, "main"], {
+ cwd: repoPath,
+ });
+
+ const checkout = await getCheckoutRef(worktreePath);
+ expect(checkout).toMatchObject({ kind: "branch", branchName: "bb/test" });
+ expect(checkout).not.toHaveProperty("jj");
+ });
+
+ it("reports @'s snapshotted edits as uncommitted changes in getStatus", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "main", "-r", "@-"], repoPath);
+ await fs.writeFile(path.join(repoPath, "README.md"), "changed\n", "utf8");
+ await fs.writeFile(path.join(repoPath, "new.txt"), "new\n", "utf8");
+ // Force a jj snapshot so the edits live in @ and git sees them as
+ // unstaged + intent-to-add entries relative to HEAD (= @-).
+ await runJj(["status"], repoPath);
+
+ const workspace = new Workspace(repoPath);
+ const status = await workspace.getStatus();
+ expect(status.checkout).toMatchObject({
+ kind: "detached",
+ jj: { bookmark: "main" },
+ });
+ expect(status.workingTree.hasUncommittedChanges).toBe(true);
+ const filePaths = status.workingTree.files.map((file) => file.path).sort();
+ expect(filePaths).toEqual(["README.md", "new.txt"]);
+ });
+
+ it("refuses commit in the jj main workspace with a typed jj_workspace error", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await fs.writeFile(path.join(repoPath, "new.txt"), "new\n", "utf8");
+
+ const workspace = new Workspace(repoPath);
+ await expect(
+ workspace.commit({ message: "test", noVerify: true }),
+ ).rejects.toMatchObject({
+ name: "WorkspaceError",
+ code: "jj_workspace",
+ });
+ });
+
+ it("commits normally in a managed worktree of a jj repo", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "main", "-r", "@-"], repoPath);
+ const worktreeParent = await makeTempDir("bb-jj-worktree-parent-");
+ const worktreePath = path.join(worktreeParent, "feature");
+ await runGit(["worktree", "add", "-B", "bb/test", worktreePath, "main"], {
+ cwd: repoPath,
+ });
+ await runGit(["config", "user.name", "BB Tests"], { cwd: worktreePath });
+ await runGit(["config", "user.email", "bb@example.com"], {
+ cwd: worktreePath,
+ });
+ await fs.writeFile(path.join(worktreePath, "work.txt"), "work\n", "utf8");
+
+ const workspace = new Workspace(worktreePath);
+ const result = await workspace.commit({
+ message: "worktree commit",
+ noVerify: true,
+ });
+ expect(result.commitSubject).toBe("worktree commit");
+
+ // The commit imports into jj as the bb/test bookmark, without conflicts
+ // or duplicate heads in the source workspace.
+ const bookmarks = await runJj(["bookmark", "list", "--all"], repoPath);
+ expect(bookmarks).toContain("bb/test");
+ });
+
+ it("reset converges cleanly with jj's working-copy snapshotting", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await fs.writeFile(path.join(repoPath, "README.md"), "changed\n", "utf8");
+ await fs.writeFile(path.join(repoPath, "junk.txt"), "junk\n", "utf8");
+ await runJj(["status"], repoPath);
+
+ const workspace = new Workspace(repoPath);
+ await workspace.reset();
+
+ const porcelain = await runGit(["status", "--porcelain"], {
+ cwd: repoPath,
+ });
+ expect(porcelain.stdout.trim()).toBe("");
+ // jj snapshots the restored state without stray heads or conflicts.
+ const jjStatus = await runJj(["status"], repoPath);
+ expect(jjStatus).toContain("The working copy has no changes.");
+ });
+
+ it("rejects squash merge from the jj main workspace as detached_head", async () => {
+ const repoPath = await initColocatedJjRepo();
+ await runJj(["bookmark", "create", "main", "-r", "@-"], repoPath);
+
+ const workspace = new Workspace(repoPath);
+ await expect(
+ workspace.squashMergeInto({
+ targetBranch: "main",
+ commitMessage: "squash",
+ }),
+ ).rejects.toMatchObject({
+ name: "WorkspaceError",
+ code: "detached_head",
+ });
+ });
+});
diff --git a/packages/host-workspace/test/jj-layout.test.ts b/packages/host-workspace/test/jj-layout.test.ts
new file mode 100644
index 0000000000..72598a63dd
--- /dev/null
+++ b/packages/host-workspace/test/jj-layout.test.ts
@@ -0,0 +1,120 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ detectColocatedJjSource,
+ resolveJjWorkspaceLayout,
+ runJj,
+} from "../src/jj.js";
+import { runGit } from "../src/git.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ // macOS temp dirs are symlinked through /private; resolve so path
+ // comparisons against jj's own output match.
+ return await fs.realpath(dir);
+}
+
+async function initColocatedJjSource(): Promise {
+ const repoPath = await makeTempDir("bb-jj-source-");
+ await runJj(["git", "init", "--colocate"], { cwd: repoPath });
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], {
+ cwd: repoPath,
+ });
+ await runJj(["config", "set", "--repo", "user.email", "bb@example.com"], {
+ cwd: repoPath,
+ });
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], { cwd: repoPath });
+ await runJj(["bookmark", "create", "main", "-r", "@-"], { cwd: repoPath });
+ return repoPath;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
+ );
+});
+
+describe.skipIf(!jjAvailable)("jj workspace layout", () => {
+ it("resolves a secondary workspace back to its source repository", async () => {
+ const sourcePath = await initColocatedJjSource();
+ const parent = await makeTempDir("bb-jj-workspaces-");
+ const workspacePath = path.join(parent, "thread");
+ await runJj(
+ ["workspace", "add", "--name", "bb/thread-1", workspacePath, "-r", "main"],
+ { cwd: sourcePath },
+ );
+
+ const layout = await resolveJjWorkspaceLayout(workspacePath);
+ expect(layout).toEqual({
+ kind: "secondary",
+ repoPath: path.join(sourcePath, ".jj", "repo"),
+ sourcePath,
+ });
+ // A secondary workspace has no git repository of its own.
+ await expect(
+ fs.lstat(path.join(workspacePath, ".git")),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("reports the main workspace and recognizes it as a provisioning source", async () => {
+ const sourcePath = await initColocatedJjSource();
+
+ expect(await resolveJjWorkspaceLayout(sourcePath)).toEqual({
+ kind: "main",
+ repoPath: path.join(sourcePath, ".jj", "repo"),
+ sourcePath,
+ });
+ expect(await detectColocatedJjSource(sourcePath)).toBe(true);
+ });
+
+ it("refuses a plain git repository and a bb-managed worktree as jj sources", async () => {
+ const repoPath = await makeTempDir("bb-jj-plain-");
+ await runGit(["init", "-b", "main"], { cwd: repoPath });
+ await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath });
+ await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath });
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runGit(["add", "README.md"], { cwd: repoPath });
+ await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath });
+
+ expect(await resolveJjWorkspaceLayout(repoPath)).toBeNull();
+ expect(await detectColocatedJjSource(repoPath)).toBe(false);
+
+ const jjSource = await initColocatedJjSource();
+ const parent = await makeTempDir("bb-jj-worktree-parent-");
+ const worktreePath = path.join(parent, "feature");
+ await runGit(["worktree", "add", "-B", "bb/test", worktreePath, "main"], {
+ cwd: jjSource,
+ });
+ // A git worktree of a jj repo carries no .jj, so it stays plain git.
+ expect(await resolveJjWorkspaceLayout(worktreePath)).toBeNull();
+ });
+
+ it("surfaces jj failures as typed workspace errors", async () => {
+ const repoPath = await makeTempDir("bb-jj-not-a-repo-");
+
+ await expect(runJj(["status"], { cwd: repoPath })).rejects.toMatchObject({
+ name: "WorkspaceError",
+ code: "jj_command_failed",
+ });
+ const allowed = await runJj(["status"], {
+ cwd: repoPath,
+ allowFailure: true,
+ });
+ expect(allowed.exitCode).not.toBe(0);
+ });
+});
diff --git a/packages/host-workspace/test/jj-provisioning.test.ts b/packages/host-workspace/test/jj-provisioning.test.ts
new file mode 100644
index 0000000000..b1fc3928ef
--- /dev/null
+++ b/packages/host-workspace/test/jj-provisioning.test.ts
@@ -0,0 +1,279 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import { runGit } from "../src/git.js";
+import { runJj } from "../src/jj.js";
+import { createWorktree, removeWorktree } from "../src/provisioning.js";
+import { provisionWorkspace } from "../src/provision.js";
+import { resolveAdditionalWorkspaceWriteRoots } from "../src/workspace-write-roots.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return await fs.realpath(dir);
+}
+
+async function initColocatedSource(): Promise {
+ const sourcePath = await makeTempDir("bb-jj-provision-source-");
+ await runJj(["git", "init", "--colocate"], { cwd: sourcePath });
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], {
+ cwd: sourcePath,
+ });
+ await runJj(["config", "set", "--repo", "user.email", "bb@example.com"], {
+ cwd: sourcePath,
+ });
+ await fs.writeFile(path.join(sourcePath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], { cwd: sourcePath });
+ await runJj(["bookmark", "create", "main", "-r", "@-"], { cwd: sourcePath });
+ return sourcePath;
+}
+
+async function provision(sourcePath: string, branchName: string) {
+ const parent = await makeTempDir("bb-jj-provision-target-");
+ const targetPath = path.join(parent, "repo");
+ await createWorktree({
+ sourcePath,
+ targetPath,
+ branchName,
+ baseBranch: "main",
+ timeoutMs: 60_000,
+ pruneEmptyParent: true,
+ });
+ return targetPath;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
+ );
+});
+
+describe.skipIf(!jjAvailable)("provisioning against a colocated jj source", () => {
+ it("creates a jj workspace that git can also read", async () => {
+ const sourcePath = await initColocatedSource();
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+
+ const workspaces = await runJj(
+ ["workspace", "list", "-T", 'name ++ "\\n"'],
+ { cwd: sourcePath },
+ );
+ expect(workspaces.stdout.split("\n")).toContain("bb/thread-1");
+ // The shadow git registration is what keeps every git-based read working.
+ const gitDir = await runGit(["rev-parse", "--git-dir"], {
+ cwd: targetPath,
+ });
+ expect(gitDir.stdout).toContain("/worktrees/");
+ expect(
+ (await runGit(["status", "--porcelain"], { cwd: targetPath })).stdout.trim(),
+ ).toBe("");
+ });
+
+ it("provisions a workspace that commits through jj and reports its bookmark", async () => {
+ const sourcePath = await initColocatedSource();
+ const parent = await makeTempDir("bb-jj-provision-target-");
+ const targetPath = path.join(parent, "repo");
+
+ const hostWorkspace = await provisionWorkspace({
+ workspaceProvisionType: "managed-worktree",
+ sourcePath,
+ targetPath,
+ branchName: "bb/thread-1",
+ baseBranch: "main",
+ timeoutMs: 60_000,
+ });
+ expect(hostWorkspace.isWorktree).toBe(true);
+ // The server names the checkout from this: a jj source gets a workspace.
+ expect(hostWorkspace.vcs).toBe("jj");
+
+ await fs.writeFile(path.join(targetPath, "work.txt"), "work\n", "utf8");
+ const status = await hostWorkspace.getStatus();
+ expect(status.workingTree.files.map((file) => file.path)).toEqual([
+ "work.txt",
+ ]);
+
+ const commit = await hostWorkspace.commit({
+ message: "thread work",
+ noVerify: true,
+ });
+ // Committed through jj: the bookmark moved and no anonymous head was left
+ // behind in the source repository.
+ const bookmark = await runJj(
+ ["log", "--no-graph", "-r", "bb/thread-1", "-T", "commit_id"],
+ { cwd: sourcePath },
+ );
+ expect(bookmark.stdout.trim()).toBe(commit.commitSha);
+ });
+
+ it("grants agents write access to the repository state outside the workspace", async () => {
+ const sourcePath = await initColocatedSource();
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+
+ const roots = await resolveAdditionalWorkspaceWriteRoots(targetPath);
+ expect(roots).toContain(path.join(sourcePath, ".jj", "repo"));
+ expect(roots).toContain(path.join(sourcePath, ".git", "objects"));
+ });
+
+ it("forgets the jj workspace when the checkout is removed", async () => {
+ const sourcePath = await initColocatedSource();
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+
+ await removeWorktree({ path: targetPath, force: true, pruneEmptyParent: true });
+
+ const workspaces = await runJj(
+ ["workspace", "list", "-T", 'name ++ "\\n"'],
+ { cwd: sourcePath },
+ );
+ expect(workspaces.stdout.split("\n")).not.toContain("bb/thread-1");
+ const worktrees = await runGit(["worktree", "list"], { cwd: sourcePath });
+ expect(worktrees.stdout).not.toContain(targetPath);
+ await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("reuses an existing workspace and rejects a mismatched one", async () => {
+ const sourcePath = await initColocatedSource();
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+
+ // Re-provisioning the same environment must be idempotent.
+ await expect(
+ createWorktree({
+ sourcePath,
+ targetPath,
+ branchName: "bb/thread-1",
+ baseBranch: "main",
+ timeoutMs: 60_000,
+ pruneEmptyParent: true,
+ }),
+ ).resolves.toMatchObject({ path: targetPath });
+
+ await expect(
+ createWorktree({
+ sourcePath,
+ targetPath,
+ branchName: "bb/other-thread",
+ baseBranch: "main",
+ timeoutMs: 60_000,
+ pruneEmptyParent: true,
+ }),
+ ).rejects.toMatchObject({ code: "path_exists" });
+ });
+
+ it("bases a workspace on a remote-tracking branch", async () => {
+ // bb resolves default bases in git's spelling ("origin/main"). jj has no
+ // such revision — the same commit is the remote bookmark "main@origin".
+ const upstream = await initColocatedSource();
+ const remoteParent = await makeTempDir("bb-jj-remote-");
+ const remotePath = path.join(remoteParent, "remote.git");
+ await runGit(["clone", "--bare", upstream, remotePath], {
+ cwd: remoteParent,
+ });
+ const cloneParent = await makeTempDir("bb-jj-clone-");
+ const clonePath = path.join(cloneParent, "repo");
+ await runJj(["git", "clone", "--colocate", remotePath, clonePath], {
+ cwd: cloneParent,
+ });
+
+ const parent = await makeTempDir("bb-jj-provision-target-");
+ const targetPath = path.join(parent, "repo");
+ await createWorktree({
+ sourcePath: clonePath,
+ targetPath,
+ branchName: "bb/thread-1",
+ baseBranch: "origin/main",
+ timeoutMs: 60_000,
+ pruneEmptyParent: true,
+ });
+
+ const head = await runGit(["rev-parse", "HEAD"], { cwd: targetPath });
+ const remoteMain = await runGit(["rev-parse", "refs/remotes/origin/main"], {
+ cwd: clonePath,
+ });
+ expect(head.stdout.trim()).toBe(remoteMain.stdout.trim());
+ });
+
+ it("leaves nothing registered when provisioning fails", async () => {
+ const sourcePath = await initColocatedSource();
+ const parent = await makeTempDir("bb-jj-provision-target-");
+ const targetPath = path.join(parent, "repo");
+
+ // jj creates the workspace before it resolves the revision, so a bad base
+ // fails with a workspace already registered. Rollback has to undo that, or
+ // the next attempt trips over the leftover.
+ await expect(
+ createWorktree({
+ sourcePath,
+ targetPath,
+ branchName: "bb/thread-1",
+ baseBranch: "no-such-branch",
+ timeoutMs: 60_000,
+ pruneEmptyParent: true,
+ }),
+ ).rejects.toThrow();
+
+ const workspaces = await runJj(
+ ["workspace", "list", "-T", 'name ++ "\\n"'],
+ { cwd: sourcePath },
+ );
+ expect(workspaces.stdout.split("\n")).not.toContain("bb/thread-1");
+ await expect(fs.stat(targetPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("still provisions plain git sources as git worktrees", async () => {
+ const sourcePath = await makeTempDir("bb-jj-plain-source-");
+ await runGit(["init", "-b", "main"], { cwd: sourcePath });
+ await runGit(["config", "user.name", "BB Tests"], { cwd: sourcePath });
+ await runGit(["config", "user.email", "bb@example.com"], { cwd: sourcePath });
+ await fs.writeFile(path.join(sourcePath, "README.md"), "hello\n", "utf8");
+ await runGit(["add", "."], { cwd: sourcePath });
+ await runGit(["commit", "-m", "Initial commit"], { cwd: sourcePath });
+
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+ const branch = await runGit(["symbolic-ref", "--short", "HEAD"], {
+ cwd: targetPath,
+ });
+ expect(branch.stdout.trim()).toBe("bb/thread-1");
+ const hostWorkspace = await provisionWorkspace({
+ workspaceProvisionType: "reconnect-managed-worktree",
+ path: targetPath,
+ });
+ expect(hostWorkspace.vcs).toBe("git");
+ await expect(fs.stat(path.join(targetPath, ".jj"))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ });
+});
+
+describe.skipIf(!jjAvailable)("reconnecting to a jj workspace", () => {
+ it("rebuilds the jj-backed workspace from the directory alone", async () => {
+ const sourcePath = await initColocatedSource();
+ const targetPath = await provision(sourcePath, "bb/thread-1");
+
+ // A daemon restart reconnects with nothing but the path on disk.
+ const reconnected = await provisionWorkspace({
+ workspaceProvisionType: "reconnect-managed-worktree",
+ path: targetPath,
+ });
+ await fs.writeFile(path.join(targetPath, "work.txt"), "work\n", "utf8");
+ const commit = await reconnected.commit({
+ message: "after restart",
+ noVerify: true,
+ });
+
+ const bookmark = await runJj(
+ ["log", "--no-graph", "-r", "bb/thread-1", "-T", "commit_id"],
+ { cwd: sourcePath },
+ );
+ expect(bookmark.stdout.trim()).toBe(commit.commitSha);
+ });
+});
diff --git a/packages/host-workspace/test/jj-workspace.test.ts b/packages/host-workspace/test/jj-workspace.test.ts
new file mode 100644
index 0000000000..7d8be1c704
--- /dev/null
+++ b/packages/host-workspace/test/jj-workspace.test.ts
@@ -0,0 +1,223 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import { runGit } from "../src/git.js";
+import { JjWorkspace } from "../src/jj-workspace.js";
+import {
+ attachShadowGitCheckout,
+ resolveJjWorkspaceLayout,
+ runJj,
+ type JjWorkspaceLayout,
+} from "../src/jj.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return await fs.realpath(dir);
+}
+
+async function initSource(): Promise {
+ const sourcePath = await makeTempDir("bb-jj-source-");
+ await runJj(["git", "init", "--colocate"], { cwd: sourcePath });
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], {
+ cwd: sourcePath,
+ });
+ await runJj(["config", "set", "--repo", "user.email", "bb@example.com"], {
+ cwd: sourcePath,
+ });
+ await fs.writeFile(path.join(sourcePath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], { cwd: sourcePath });
+ await runJj(["bookmark", "create", "main", "-r", "@-"], { cwd: sourcePath });
+ return sourcePath;
+}
+
+async function addWorkspace(
+ sourcePath: string,
+ bookmark: string,
+): Promise<{ workspace: JjWorkspace; workspacePath: string }> {
+ const parent = await makeTempDir("bb-jj-workspaces-");
+ const workspacePath = path.join(parent, "thread");
+ await runJj(
+ ["workspace", "add", "--name", bookmark, workspacePath, "-r", "main"],
+ { cwd: sourcePath },
+ );
+ await attachShadowGitCheckout({ sourcePath, workspacePath });
+ const layout = (await resolveJjWorkspaceLayout(
+ workspacePath,
+ )) as JjWorkspaceLayout;
+ return {
+ workspace: new JjWorkspace({ path: workspacePath, layout, bookmark }),
+ workspacePath,
+ };
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
+ );
+});
+
+describe.skipIf(!jjAvailable)("bb-managed jj workspaces", () => {
+ it("reports jj's working-copy edits as uncommitted changes", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+
+ expect((await workspace.getStatus()).workingTree.hasUncommittedChanges).toBe(
+ false,
+ );
+
+ await fs.writeFile(path.join(workspacePath, "README.md"), "edited\n", "utf8");
+ await fs.writeFile(path.join(workspacePath, "added.txt"), "added\n", "utf8");
+
+ const status = await workspace.getStatus();
+ expect(status.workingTree.hasUncommittedChanges).toBe(true);
+ expect(status.workingTree.files.map((file) => file.path).sort()).toEqual([
+ "README.md",
+ "added.txt",
+ ]);
+ // The shadow checkout stays detached at @-, with the workspace's bookmark
+ // reported for display.
+ expect(status.checkout).toMatchObject({ kind: "detached" });
+ // .jj is jj's own bookkeeping and must never surface as a change.
+ expect(
+ status.workingTree.files.some((file) => file.path.startsWith(".jj")),
+ ).toBe(false);
+
+ const diff = await workspace.getDiff();
+ expect(diff.diff).toContain("added.txt");
+ expect(diff.diff).toContain("edited");
+ });
+
+ it("commits with jj, moves the bookmark, and exports it to git", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+ await fs.writeFile(path.join(workspacePath, "work.txt"), "work\n", "utf8");
+
+ const result = await workspace.commit({
+ message: "thread work",
+ noVerify: true,
+ });
+ expect(result.commitSubject).toBe("thread work");
+
+ // The bookmark is a real git ref in the source repository...
+ const ref = await runGit(
+ ["rev-parse", "--verify", "refs/heads/bb/thread-1"],
+ { cwd: sourcePath },
+ );
+ expect(ref.stdout.trim()).toBe(result.commitSha);
+ // ...and jj sees one commit, not a stray anonymous head beside it.
+ const heads = await runJj(
+ ["log", "--no-graph", "-r", "bb/thread-1", "-T", 'description.first_line() ++ "\\n"'],
+ { cwd: sourcePath },
+ );
+ expect(heads.stdout.trim()).toBe("thread work");
+
+ const status = await workspace.getStatus();
+ expect(status.workingTree.hasUncommittedChanges).toBe(false);
+ });
+
+ it("refuses an empty commit and discards changes on reset", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+
+ await expect(
+ workspace.commit({ message: "nothing", noVerify: true }),
+ ).rejects.toMatchObject({ name: "WorkspaceError", code: "no_changes" });
+
+ await fs.writeFile(path.join(workspacePath, "junk.txt"), "junk\n", "utf8");
+ await fs.writeFile(path.join(workspacePath, "README.md"), "edited\n", "utf8");
+ await workspace.reset();
+
+ expect((await workspace.getStatus()).workingTree.hasUncommittedChanges).toBe(
+ false,
+ );
+ await expect(
+ fs.readFile(path.join(workspacePath, "README.md"), "utf8"),
+ ).resolves.toBe("hello\n");
+ });
+
+ it("squash merges committed work into the source repository's branch", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+ await fs.writeFile(path.join(workspacePath, "work.txt"), "work\n", "utf8");
+ await workspace.commit({ message: "thread work", noVerify: true });
+
+ const merge = await workspace.squashMergeInto({
+ targetBranch: "main",
+ commitMessage: "squashed thread work",
+ });
+ expect(merge.merged).toBe(true);
+
+ // jj imports the moved git ref, so main carries the squash commit.
+ const mainLog = await runJj(
+ ["log", "--no-graph", "-r", "main", "-T", 'description.first_line() ++ "\\n"'],
+ { cwd: sourcePath },
+ );
+ expect(mainLog.stdout.trim()).toBe("squashed thread work");
+ });
+
+ it("keeps the local fingerprint stable across repeated reads", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+
+ // Every read snapshots the working copy. If that made the fingerprint
+ // move on its own, the watcher would re-read forever.
+ const idle = await workspace.getLocalStateFingerprint();
+ expect(await workspace.getLocalStateFingerprint()).toBe(idle);
+ expect(await workspace.getLocalStateFingerprint()).toBe(idle);
+
+ await fs.writeFile(path.join(workspacePath, "work.txt"), "work\n", "utf8");
+ const dirty = await workspace.getLocalStateFingerprint();
+ expect(dirty).not.toBe(idle);
+ expect(await workspace.getLocalStateFingerprint()).toBe(dirty);
+ });
+
+ it("picks up commits an agent made with jj directly", async () => {
+ const sourcePath = await initSource();
+ const { workspace, workspacePath } = await addWorkspace(
+ sourcePath,
+ "bb/thread-1",
+ );
+ await fs.writeFile(path.join(workspacePath, "agent.txt"), "agent\n", "utf8");
+ // An agent using jj moves @- without bb involvement; the shadow checkout
+ // has to follow before anything reads the workspace.
+ await runJj(["commit", "-m", "agent commit"], { cwd: workspacePath });
+
+ const status = await workspace.getStatus();
+ expect(status.workingTree.hasUncommittedChanges).toBe(false);
+ expect(await workspace.getHeadSha()).toBe(
+ (
+ await runJj(["log", "--no-graph", "-r", "@-", "-T", "commit_id"], {
+ cwd: workspacePath,
+ })
+ ).stdout.trim(),
+ );
+ });
+});
diff --git a/packages/host-workspace/test/provisioning-jj.test.ts b/packages/host-workspace/test/provisioning-jj.test.ts
new file mode 100644
index 0000000000..d309f4fa47
--- /dev/null
+++ b/packages/host-workspace/test/provisioning-jj.test.ts
@@ -0,0 +1,84 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it } from "vitest";
+import { Workspace } from "../src/workspace.js";
+import { readDefaultBranch, runGit } from "../src/git.js";
+
+const execFileAsync = promisify(execFile);
+
+const jjAvailable = await execFileAsync("jj", ["--version"]).then(
+ () => true,
+ () => false,
+);
+
+const tempDirs: string[] = [];
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+async function runJj(args: string[], cwd: string): Promise {
+ const result = await execFileAsync("jj", args, { cwd });
+ return result.stdout;
+}
+
+async function initColocatedJjRepo(): Promise {
+ const repoPath = await makeTempDir("bb-jj-provisioning-repo-");
+ await runJj(["git", "init", "--colocate"], repoPath);
+ await runJj(["config", "set", "--repo", "user.name", "BB Tests"], repoPath);
+ await runJj(
+ ["config", "set", "--repo", "user.email", "bb@example.com"],
+ repoPath,
+ );
+ await fs.writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8");
+ await runJj(["commit", "-m", "Initial commit"], repoPath);
+ await runJj(["bookmark", "create", "main", "-r", "@-"], repoPath);
+ return repoPath;
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirs.splice(0).map((dir) =>
+ fs.rm(dir, { recursive: true, force: true }),
+ ),
+ );
+});
+
+describe.skipIf(!jjAvailable)("provisioning from colocated jj sources", () => {
+ it("resolves the default branch in a colocated jj clone", async () => {
+ const upstream = await initColocatedJjRepo();
+ const remoteParent = await makeTempDir("bb-jj-remote-");
+ const remotePath = path.join(remoteParent, "remote.git");
+ await runGit(["clone", "--bare", upstream, remotePath], {
+ cwd: remoteParent,
+ });
+
+ const cloneParent = await makeTempDir("bb-jj-clone-parent-");
+ const clonePath = path.join(cloneParent, "repo");
+ await runJj(
+ ["git", "clone", "--colocate", remotePath, clonePath],
+ cloneParent,
+ );
+
+ const defaultBranch = await readDefaultBranch(clonePath);
+ expect(defaultBranch).toBe("main");
+ });
+
+ it("changes the shared refs fingerprint when a bookmark moves", async () => {
+ const repoPath = await initColocatedJjRepo();
+ const workspace = new Workspace(repoPath);
+ const before = await workspace.getSharedGitRefsFingerprint();
+
+ await fs.writeFile(path.join(repoPath, "more.txt"), "more\n", "utf8");
+ await runJj(["commit", "-m", "Second commit"], repoPath);
+ await runJj(["bookmark", "move", "main", "--to", "@-"], repoPath);
+
+ const after = await workspace.getSharedGitRefsFingerprint();
+ expect(after).not.toBe(before);
+ });
+});