* fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it.
463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import type {
|
|
DaemonClient,
|
|
FetchAgentsEntry,
|
|
FetchAgentsOptions,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
|
import {
|
|
normalizeProjectDescriptor,
|
|
normalizeWorkspaceDescriptor,
|
|
useSessionStore,
|
|
type Agent,
|
|
type ProjectDescriptor,
|
|
type WorkspaceDescriptor,
|
|
} from "@/stores/session-store";
|
|
import {
|
|
readLegacyDaemonWorkspaceDirectory,
|
|
buildLegacyWorkspaces,
|
|
shouldUseLegacyDaemonWorkspaceDirectory,
|
|
stampLegacyWorkspaceIds,
|
|
} from "@/workspace/legacy-daemon-workspaces";
|
|
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
|
import { AgentDirectoryReplica } from "./agent-replica";
|
|
import {
|
|
WorkspaceDirectoryReplica,
|
|
type WorkspaceDirectoryDelta,
|
|
type WorkspaceDirectorySnapshot,
|
|
} from "./workspace-replica";
|
|
import {
|
|
DirectoryTransactionOwner,
|
|
type DirectorySourceToken,
|
|
type DirectoryTransaction,
|
|
} from "./transaction";
|
|
|
|
const PAGE_LIMIT = 200;
|
|
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
|
{ key: "updated_at", direction: "desc" },
|
|
];
|
|
|
|
interface AgentSnapshot {
|
|
entries: FetchAgentsEntry[];
|
|
subscriptionId: string | null;
|
|
legacy: boolean;
|
|
}
|
|
|
|
export interface DirectoryConnection {
|
|
client: DaemonClient | null;
|
|
status: "online" | "offline";
|
|
source: DirectorySourceToken;
|
|
}
|
|
|
|
export interface RefreshAgentDirectoryInput {
|
|
filter?: FetchAgentsOptions["filter"];
|
|
subscribe?: FetchAgentsOptions["subscribe"];
|
|
page?: FetchAgentsOptions["page"];
|
|
}
|
|
|
|
export interface RefreshAgentDirectoryResult {
|
|
agents: Map<string, Agent>;
|
|
subscriptionId: string | null;
|
|
}
|
|
|
|
export class DirectorySync {
|
|
private readonly agentTransactions = new DirectoryTransactionOwner<
|
|
AgentSnapshot,
|
|
AgentDirectoryDelta
|
|
>();
|
|
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
|
WorkspaceDirectorySnapshot,
|
|
WorkspaceDirectoryDelta
|
|
>();
|
|
private readonly agents: AgentDirectoryReplica;
|
|
private readonly workspaces: WorkspaceDirectoryReplica;
|
|
private connection: DirectoryConnection = {
|
|
client: null,
|
|
status: "offline",
|
|
source: { clientGeneration: 0, connectionEpoch: 0 },
|
|
};
|
|
private unsubscribe: (() => void) | null = null;
|
|
private readonly abortSessionWaits = new Set<() => void>();
|
|
|
|
constructor(
|
|
private readonly serverId: string,
|
|
private readonly callbacks: {
|
|
drainQueuedAgentMessage: (agentId: string) => void;
|
|
markAgentLoading: () => void;
|
|
markAgentReady: () => void;
|
|
markAgentError: (error: string) => void;
|
|
},
|
|
) {
|
|
this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage);
|
|
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
|
}
|
|
|
|
connectionChanged(connection: DirectoryConnection): boolean {
|
|
const changed =
|
|
this.connection.client !== connection.client ||
|
|
this.connection.source.clientGeneration !== connection.source.clientGeneration ||
|
|
this.connection.source.connectionEpoch !== connection.source.connectionEpoch;
|
|
const wentOffline = this.connection.status === "online" && connection.status === "offline";
|
|
if (!changed && !wentOffline) {
|
|
this.connection = connection;
|
|
return false;
|
|
}
|
|
this.flushAbortedTransactions();
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
this.connection = connection;
|
|
this.abortPendingSessionWaits();
|
|
if (!connection.client || connection.status !== "online") return true;
|
|
const client = connection.client;
|
|
const source = connection.source;
|
|
this.workspaceTransactions.begin(source, () => ({
|
|
workspaces: new Map(),
|
|
projects: new Map(),
|
|
}));
|
|
const subscriptions = [
|
|
client.on("agent_update", (message) => {
|
|
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
|
if (!this.agentTransactions.record(source, message.payload))
|
|
this.agents.applyDelta(message.payload);
|
|
}),
|
|
client.on("workspace_update", (message) => {
|
|
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
|
|
if (!this.workspaceTransactions.record(source, message.payload)) {
|
|
this.workspaces.applyDelta(message.payload);
|
|
}
|
|
}),
|
|
client.on("project.update", (message) => {
|
|
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
|
|
if (!this.workspaceTransactions.record(source, message.payload)) {
|
|
this.workspaces.applyDelta(message.payload);
|
|
}
|
|
}),
|
|
client.on("agent_deleted", (message) => {
|
|
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
|
this.agents.remove(message.payload.agentId);
|
|
}
|
|
}),
|
|
client.on("agent_archived", (message) => {
|
|
if (message.type === "agent_archived" && this.isCurrent(client, source)) {
|
|
this.agents.archive(message.payload.agentId, message.payload.archivedAt);
|
|
}
|
|
}),
|
|
];
|
|
this.unsubscribe = () => {
|
|
for (const unsubscribe of subscriptions) unsubscribe();
|
|
};
|
|
return true;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.flushAbortedTransactions();
|
|
this.abortPendingSessionWaits();
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
}
|
|
|
|
async fetchTimeline(
|
|
agentId: string,
|
|
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
|
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
|
const { client } = this.requireOnline();
|
|
const token = this.agents.captureTimeline(agentId);
|
|
const page = await fetchAgentTimelineOnce(client, agentId, request);
|
|
if (page.agent) this.agents.submitTimelineAgent(token, page.agent);
|
|
return page;
|
|
}
|
|
|
|
async refreshAgents(
|
|
input: RefreshAgentDirectoryInput = {},
|
|
): Promise<RefreshAgentDirectoryResult> {
|
|
const { client, source } = this.requireOnline();
|
|
const transaction = this.agentTransactions.begin(source, () => ({
|
|
entries: [],
|
|
subscriptionId: null,
|
|
legacy: false,
|
|
}));
|
|
this.callbacks.markAgentLoading();
|
|
try {
|
|
await this.waitForSession(client, source);
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) {
|
|
const directory = await readLegacyDaemonWorkspaceDirectory({
|
|
client,
|
|
subscribe: input.subscribe,
|
|
page: input.page,
|
|
});
|
|
if (
|
|
!directory ||
|
|
!this.agentTransactions.isCurrent(transaction) ||
|
|
!this.isCurrent(client, source)
|
|
) {
|
|
throw new DirectoryRefreshSupersededError("legacy fetch no longer current");
|
|
}
|
|
transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries));
|
|
transaction.snapshot.subscriptionId = directory.subscriptionId;
|
|
transaction.snapshot.legacy = true;
|
|
} else {
|
|
await this.fetchAgents(client, source, transaction, input);
|
|
}
|
|
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("agent completion no longer current");
|
|
}
|
|
const completion = this.agentTransactions.complete(transaction);
|
|
if (completion.kind === "stale") {
|
|
throw new DirectoryRefreshSupersededError("agent completion was superseded");
|
|
}
|
|
if (completion.snapshot.legacy) {
|
|
const store = useSessionStore.getState();
|
|
const workspaces = buildLegacyWorkspaces(completion.snapshot.entries);
|
|
store.setWorkspaces(this.serverId, workspaces);
|
|
store.setProjects(
|
|
this.serverId,
|
|
Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace),
|
|
);
|
|
store.setHasHydratedWorkspaces(this.serverId, true);
|
|
}
|
|
const deltas = completion.snapshot.legacy
|
|
? completion.deltas.map((delta) =>
|
|
delta.kind === "upsert"
|
|
? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } }
|
|
: delta,
|
|
)
|
|
: completion.deltas;
|
|
const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas);
|
|
this.callbacks.markAgentReady();
|
|
return { agents, subscriptionId: completion.snapshot.subscriptionId };
|
|
} catch (error) {
|
|
const deltas = this.agentTransactions.fail(transaction);
|
|
if (deltas) for (const delta of deltas) this.agents.applyDelta(delta);
|
|
if (!(error instanceof DirectoryRefreshSupersededError)) {
|
|
this.callbacks.markAgentError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
|
|
const { client, source } = this.requireOnline();
|
|
const transaction = this.workspaceTransactions.begin(source, () => ({
|
|
workspaces: new Map(),
|
|
projects: new Map(),
|
|
}));
|
|
try {
|
|
await this.waitForSessionMetadata(client, source);
|
|
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
|
if (serverInfo?.features?.workspaceMultiplicity !== true) {
|
|
const deltas = this.workspaceTransactions.fail(transaction);
|
|
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
|
return;
|
|
}
|
|
const supportsProjectList = serverInfo.features?.projectList === true;
|
|
if (supportsProjectList) {
|
|
const payload = await client.listProjects();
|
|
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
|
for (const entry of payload.projects) {
|
|
const project = normalizeProjectDescriptor(entry);
|
|
transaction.snapshot.projects.set(project.projectId, project);
|
|
}
|
|
}
|
|
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true);
|
|
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
|
|
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
|
}
|
|
const completion = this.workspaceTransactions.complete(transaction);
|
|
if (completion.kind === "stale") {
|
|
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
|
|
}
|
|
this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
|
|
} catch (error) {
|
|
const deltas = this.workspaceTransactions.fail(transaction);
|
|
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async fetchWorkspaceSnapshot(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
|
initialSubscribe: boolean,
|
|
): Promise<void> {
|
|
let cursor: string | null = null;
|
|
let subscribe = initialSubscribe;
|
|
while (true) {
|
|
const payload = await client.fetchWorkspaces({
|
|
sort: [{ key: "activity_at", direction: "desc" }],
|
|
...(subscribe ? { subscribe: {} } : {}),
|
|
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
|
});
|
|
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
|
for (const entry of payload.entries) {
|
|
const workspace = normalizeWorkspaceDescriptor(entry);
|
|
transaction.snapshot.workspaces.set(workspace.id, workspace);
|
|
}
|
|
for (const entry of payload.emptyProjects ?? []) {
|
|
const project = normalizeProjectDescriptor(entry);
|
|
transaction.snapshot.projects.set(project.projectId, project);
|
|
}
|
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
|
cursor = payload.pageInfo.nextCursor;
|
|
subscribe = false;
|
|
}
|
|
}
|
|
|
|
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
|
|
for (const workspace of snapshot.workspaces.values()) {
|
|
if (!snapshot.projects.has(workspace.projectId)) {
|
|
const project = legacyProjectDescriptorFromWorkspace(workspace);
|
|
snapshot.projects.set(project.projectId, project);
|
|
}
|
|
}
|
|
}
|
|
|
|
async refreshAll(): Promise<void> {
|
|
await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]);
|
|
}
|
|
|
|
private async fetchAgents(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
|
|
input: RefreshAgentDirectoryInput,
|
|
): Promise<void> {
|
|
let cursor = input.page?.cursor ?? null;
|
|
let subscribe = input.subscribe;
|
|
while (true) {
|
|
const limit = input.page?.limit ?? PAGE_LIMIT;
|
|
const payload = await client.fetchAgents({
|
|
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
|
|
sort: AGENT_SORT,
|
|
...(subscribe ? { subscribe } : {}),
|
|
page: cursor ? { limit, cursor } : { limit },
|
|
});
|
|
this.assertAgentTransactionCurrent(client, source, transaction);
|
|
transaction.snapshot.entries.push(...payload.entries);
|
|
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
|
|
const pageInfo = payload.pageInfo as {
|
|
hasMore?: boolean;
|
|
hasMoreAfter?: boolean;
|
|
nextCursor?: string | null;
|
|
afterCursor?: string | null;
|
|
};
|
|
const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false;
|
|
const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null;
|
|
if (!hasMore || !nextCursor) break;
|
|
cursor = nextCursor;
|
|
subscribe = undefined;
|
|
}
|
|
}
|
|
|
|
private assertAgentTransactionCurrent(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
|
|
): void {
|
|
if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("agent page no longer current");
|
|
}
|
|
}
|
|
|
|
private assertWorkspaceTransactionCurrent(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
|
): void {
|
|
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
|
}
|
|
}
|
|
|
|
private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } {
|
|
if (!this.connection.client || this.connection.status !== "online") {
|
|
throw new Error(`Host ${this.serverId} is not connected`);
|
|
}
|
|
return { client: this.connection.client, source: this.connection.source };
|
|
}
|
|
|
|
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
|
|
return (
|
|
this.connection.client === client &&
|
|
this.connection.status === "online" &&
|
|
this.connection.source.clientGeneration === source.clientGeneration &&
|
|
this.connection.source.connectionEpoch === source.connectionEpoch
|
|
);
|
|
}
|
|
|
|
private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise<void> {
|
|
await this.waitForSessionState(client, source, () => this.hasMatchingSession(client, source));
|
|
}
|
|
|
|
private async waitForSessionMetadata(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
): Promise<void> {
|
|
await this.waitForSessionState(client, source, () => {
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
|
|
});
|
|
}
|
|
|
|
private async waitForSessionState(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
matches: () => boolean,
|
|
): Promise<void> {
|
|
if (matches()) return;
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
let unsubscribe: () => void = () => undefined;
|
|
const finish = (result: "ready" | "aborted") => {
|
|
if (settled) return;
|
|
settled = true;
|
|
unsubscribe();
|
|
this.abortSessionWaits.delete(abort);
|
|
if (result === "ready") resolve();
|
|
else reject(new DirectoryRefreshSupersededError("session wait no longer current"));
|
|
};
|
|
const abort = () => finish("aborted");
|
|
const check = () => {
|
|
if (matches()) {
|
|
finish("ready");
|
|
} else if (!this.isCurrent(client, source)) {
|
|
finish("aborted");
|
|
}
|
|
};
|
|
this.abortSessionWaits.add(abort);
|
|
unsubscribe = useSessionStore.subscribe(check);
|
|
check();
|
|
});
|
|
}
|
|
|
|
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
return session?.client === client && session.clientGeneration === source.clientGeneration;
|
|
}
|
|
|
|
private flushAbortedTransactions(): void {
|
|
for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta);
|
|
for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta);
|
|
}
|
|
|
|
private abortPendingSessionWaits(): void {
|
|
for (const abort of this.abortSessionWaits) abort();
|
|
}
|
|
}
|
|
|
|
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
|
return {
|
|
projectId: workspace.projectId,
|
|
projectKey: null,
|
|
projectDisplayName: workspace.projectDisplayName,
|
|
projectCustomName: workspace.projectCustomName ?? null,
|
|
projectRootPath: workspace.projectRootPath,
|
|
projectKind: workspace.projectKind,
|
|
};
|
|
}
|
|
|
|
export class DirectoryRefreshSupersededError extends Error {}
|
|
|
|
export type { DirectorySourceToken } from "./transaction";
|