Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/server/src/project/threadWorkspaceRoot.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";

import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
import * as ProjectService from "./ProjectService.ts";

/** The directory a thread works in, or undefined when the thread or its project is gone. */
Expand All @@ -17,3 +19,25 @@ export const threadWorkspaceRoot = Effect.fn("threadWorkspaceRoot")(
},
Effect.orElseSucceed(() => undefined),
);

/**
* Where to scan `cwd`'s workspace inventory. A thread's removed managed worktree scans its
* project, which turn start recreates the worktree from.
*/
export const workspaceScanDirectory = Effect.fn("workspaceScanDirectory")(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
function* (cwd: string) {
const fileSystem = yield* FileSystem.FileSystem;
if (yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true))) return cwd;
const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
if (!(yield* isManagedWorktreePath(cwd))) return cwd;
const threads = yield* ThreadManagementService.ThreadManagementService;
const projects = yield* ProjectService.ProjectService;
const snapshot = yield* Effect.flatten(threads.readShellSnapshot({ worktreePath: cwd }));
const thread = [...snapshot.threads, ...snapshot.archivedThreads].find(
(candidate) => candidate.branch !== null,
);
if (thread === undefined) return cwd;
return Option.getOrUndefined(yield* projects.getById(thread.projectId))?.workspaceRoot ?? cwd;
},
(effect, cwd) => Effect.orElseSucceed(effect, () => cwd),
);
91 changes: 91 additions & 0 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,97 @@ it.layer(
}),
);

it.effect("scans scanCwd and keys the workspace snapshot by cwd", () =>
Effect.gen(function* () {
const driver = ProviderDriverKind.make("codex");
const instanceId = ProviderInstanceId.make("codex");
const provider = {
instanceId,
driver,
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated" },
checkedAt: "2026-06-10T00:00:00.000Z",
version: "1.0.0",
models: [],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const scannedDirectories = yield* Ref.make<ReadonlyArray<string>>([]);
const instance: ProviderInstance = {
instanceId,
driverKind: driver,
continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" },
displayName: undefined,
enabled: true,
snapshot: {
resolveMaintenance: () =>
Effect.succeed(
makeManualOnlyProviderMaintenanceCapabilities({
provider: driver,
packageName: null,
}),
),
getSnapshot: Effect.succeed(provider),
refresh: Effect.succeed(provider),
streamChanges: Stream.empty,
applyUsageLimits: () => Effect.void,
},
snapshotForCwd: (directory) =>
Ref.update(scannedDirectories, (directories) => [...directories, directory]).pipe(
Effect.as({
...provider,
skills: [{ name: "finalize", path: `${directory}/finalize`, enabled: true }],
}),
),
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
};
const layerInstanceRegistry = Layer.succeed(
ProviderInstanceRegistry.ProviderInstanceRegistry,
{
getInstance: (requestedId) =>
Effect.succeed(requestedId === instanceId ? instance : undefined),
listInstances: Effect.succeed([instance]),
listUnavailable: Effect.succeed([]),
streamChanges: Stream.empty,
subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
},
);
const scope = yield* Scope.make();
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
const runtimeServices = yield* Layer.build(
ProviderRegistry.layer.pipe(
Layer.provide(layerUnusedMutator),
Layer.provideMerge(layerInstanceRegistry),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-scan-cwd-",
}),
),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
),
).pipe(Scope.provide(scope));

yield* Effect.gen(function* () {
const registry = yield* ProviderRegistry.ProviderRegistry;
const providers = yield* registry.refreshWorkspaceSnapshot({
instanceId,
cwd: "/worktree",
scanCwd: "/project",
});
assert.deepStrictEqual(yield* Ref.get(scannedDirectories), ["/project"]);
assert.deepStrictEqual(
providers[0]?.workspaceSnapshots
?.find((snapshot) => snapshot.cwd === "/worktree")
?.skills?.map((skill) => skill.path),
["/project/finalize"],
);
}).pipe(Effect.provide(runtimeServices));
}),
);

it.effect("publishes workspace skills while machine health is in error", () =>
Effect.gen(function* () {
const driver = ProviderDriverKind.make("codex");
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class ProviderRegistry extends Context.Service<
readonly refreshWorkspaceSnapshot: (input: {
readonly instanceId: ProviderInstanceId;
readonly cwd: string;
/** Directory the driver scans; the snapshot stays keyed by `cwd`. Defaults to `cwd`. */
readonly scanCwd?: string;
readonly fresh?: boolean;
}) => Effect.Effect<ReadonlyArray<ServerProvider>>;

Expand Down Expand Up @@ -951,6 +953,7 @@ export const layer = Layer.effect(
const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: {
readonly instanceId: ProviderInstanceId;
readonly cwd: string;
readonly scanCwd?: string;
readonly fresh?: boolean;
}) {
if (input.fresh) {
Expand Down Expand Up @@ -989,7 +992,7 @@ export const layer = Layer.effect(
)
: Effect.void;
const runScan = refreshMachineSnapshot.pipe(
Effect.andThen(instance.snapshotForCwd(input.cwd)),
Effect.andThen(instance.snapshotForCwd(input.scanCwd ?? input.cwd)),
// Scans fail on error; returned inventory remains valid even when provider health reports an error.
Effect.flatMap((scopedSnapshot) =>
instanceRegistry.getInstance(input.instanceId).pipe(
Expand Down
162 changes: 162 additions & 0 deletions apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Cause from "effect/Cause";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PubSub from "effect/PubSub";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -1350,6 +1352,166 @@ it.effect("on-demand clients get provider config without workspace snapshots", (
),
);

it.effect("a missing thread worktree gets its project's skills in the cwd snapshot", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfigModule.ServerConfig;
const instanceId = ProviderInstanceId.make("claudeAgent");
const projectId = ProjectId.make("project:skills");
const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-missing-worktree-project-",
});
const skillPath = path.join(workspaceRoot, ".claude", "skills", "finalize", "SKILL.md");
yield* fileSystem.makeDirectory(path.dirname(skillPath), { recursive: true });
yield* fileSystem.writeFileString(skillPath, "---\nname: finalize\n---\n");
// Removed with `git worktree remove`; only the next turn start recreates it.
const worktreePath = path.join(serverConfig.worktreesDir, "proj", "t3-6f94fb95");
const thread = {
id: ThreadId.make("thread:worktree"),
projectId,
archivedAt: null,
worktreePath,
branch: "t3/reply-ok",
} as unknown as OrchestrationV2ThreadShell;
const project = {
id: projectId,
title: "proj",
workspaceRoot,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-11-07T00:00:00.000Z",
updatedAt: "2026-11-07T00:00:00.000Z",
} satisfies OrchestrationProjectShell;
const provider: ServerProvider = {
instanceId,
driver: ProviderDriverKind.make("claudeAgent"),
enabled: true,
installed: true,
version: "1.0.0",
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-11-07T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
};
// Like Claude's probe: project skills come from the scanned cwd, and a missing cwd is final.
const scan = (cwd: string) =>
fileSystem.exists(path.join(cwd, ".claude", "skills", "finalize", "SKILL.md")).pipe(
Effect.orDie,
Effect.map((found) => ({
cwd,
checkedAt: "2026-11-07T00:00:00.000Z",
slashCommands: [],
slashCommandsPending: false,
skills: found
? [{ name: "finalize", path: path.join(cwd, ".claude/skills/finalize"), enabled: true }]
: [],
})),
);
const responses = yield* Queue.unbounded<unknown>();
const services = Layer.mergeAll(
layerUnusedConfigRpcServices,
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([provider]),
refreshWorkspaceSnapshot: (input) =>
Effect.map(scan(input.scanCwd ?? input.cwd), (snapshot) => [
{ ...provider, workspaceSnapshots: [{ ...snapshot, cwd: input.cwd }] },
]),
}),
Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
listInstances: Effect.succeed([]),
getInstance: () => Effect.succeed({ instanceId, snapshotForCwd: scan } as never),
}),
Layer.mock(ThreadManagement.ThreadManagementService)({
readShellSnapshot: (options) =>
Effect.succeed(
Effect.succeed({
schemaVersion: 1,
snapshotSequence: 0,
threads: [thread].filter(
(candidate) =>
options?.worktreePath === undefined ||
candidate.worktreePath === options.worktreePath,
),
archivedThreads: [],
} as never),
),
}),
Layer.mock(ProjectService.ProjectService)({
getById: () => Effect.succeedSome(project as never),
}),
Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]) }),
Layer.mock(ExternalLauncher.ExternalLauncher)({}),
Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}),
Layer.mock(DirectEndpoints.DirectEndpoints)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
scratchRoot: Effect.succeedNone,
namedProjectsRoot: process.cwd(),
}),
Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}),
Layer.mock(ServerEnvironment.ServerEnvironment)({}),
Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
);
const server = yield* RpcServer.makeNoSerialization(
RpcGroup.make(WsServerRefreshProvidersRpc),
{
...WS_RPC_SERVER_OPTIONS,
onFromServer: (response) =>
response._tag === "Exit" && response.exit._tag === "Success"
? Queue.offer(responses, response.exit.value)
: response._tag === "Exit"
? Queue.offer(responses, response.exit)
: Effect.void,
},
).pipe(
Effect.provide(
layerWsRpc(
{
sessionId: AuthSessionId.make("worktree-skills-test"),
subject: "worktree-skills-test",
method: "browser-session-cookie",
scopes: [],
},
{},
yield* ServerBrowser.ServerBrowser,
() => Effect.void,
{ secretReferences: true },
).pipe(Layer.provide(services)),
),
);

yield* server.write(0, {
_tag: "Request",
id: RpcMessage.RequestId("refresh"),
tag: WS_METHODS.serverRefreshProviders,
payload: { instanceId, cwd: worktreePath, workspaceSnapshotsOnDemand: true },
headers: Headers.empty,
});
const refreshed = (yield* Queue.take(responses)) as {
readonly providers: ReadonlyArray<ServerProvider>;
};
const snapshot = refreshed.providers[0]?.workspaceSnapshots?.find(
(candidate) => candidate.cwd === worktreePath,
);
assert.deepEqual(
snapshot?.skills?.map((skill) => skill.name),
["finalize"],
"the thread's removed worktree should list the project's skills",
);
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.fresh(layerConfigSources),
Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}),
Layer.mock(ServerBrowser.ServerBrowser)({}),
),
),
Effect.scoped,
),
);

it.effect("a publish whose push fails still refreshes the new remote", () =>
Effect.gen(function* () {
const refreshes = yield* Queue.unbounded<string>();
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";
import * as ProjectService from "./project/ProjectService.ts";
import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts";
import { threadWorkspaceRoot, workspaceScanDirectory } from "./project/threadWorkspaceRoot.ts";
import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts";
import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts";
import { mutateProjectWithClone } from "./project/ProjectMutation.ts";
Expand Down Expand Up @@ -2516,6 +2516,7 @@ export const layerWsRpc = (
? providerRegistry.refreshWorkspaceSnapshot({
instanceId: input.instanceId,
cwd: input.cwd,
scanCwd: yield* workspaceScanDirectory(input.cwd),
fresh: input.fresh === true,
})
: input.reloadSecrets === true
Expand Down
Loading