blob: 7edce18b892274ff2382abcc719a5fb6182cf419 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project';
import {
type ProjectCatalog,
ProjectArchivedError,
ProjectNotFoundError,
ProjectPathMismatchError,
ProjectUnavailableError,
} from '@maka/storage/project-catalog';
import type { WorkspaceTarget } from '../protocol/index.js';
import type { HostProjectMembershipGate } from './project-membership-gate.js';
export type WorkspaceResolutionErrorCode = 'invalid_request' | 'not_found' | 'operation_conflict';
export class WorkspaceResolutionError extends Error {
constructor(
readonly code: WorkspaceResolutionErrorCode,
message: string,
) {
super(message);
this.name = 'WorkspaceResolutionError';
}
}
export type ResolvedWorkspace =
| {
readonly target: Extract<WorkspaceTarget, { readonly kind: 'host_path' }>;
readonly cwd: string;
readonly projectId: null;
}
| {
readonly target: Extract<WorkspaceTarget, { readonly kind: 'project' }>;
readonly cwd: string;
readonly projectId: string;
readonly project: ProjectRecord;
};
export class HostWorkspaceResolver {
constructor(
private readonly catalog: Pick<ProjectCatalog, 'list' | 'touch'>,
private readonly membership: HostProjectMembershipGate,
private readonly onProjectChanged: () => void,
) {}
resolve(target: WorkspaceTarget): Promise<ResolvedWorkspace> {
return this.run(target, async (workspace) => workspace);
}
run<Result>(
target: WorkspaceTarget,
operation: (workspace: ResolvedWorkspace) => Promise<Result>,
): Promise<Result> {
return this.membership.run(async () => operation(await this.#resolve(target)));
}
runWithUsageRecorded<Result>(
target: WorkspaceTarget,
operation: (workspace: ResolvedWorkspace) => Promise<Result>,
): Promise<Result> {
return this.membership.run(async () => {
const workspace = await this.#resolve(target);
if (workspace.projectId !== null) {
try {
await this.catalog.touch(workspace.projectId, workspace.cwd);
} catch (error) {
if (error instanceof ProjectNotFoundError) {
throw new WorkspaceResolutionError('not_found', error.message);
}
if (
error instanceof ProjectArchivedError ||
error instanceof ProjectUnavailableError ||
error instanceof ProjectPathMismatchError
) {
throw new WorkspaceResolutionError('operation_conflict', error.message);
}
throw error;
}
this.onProjectChanged();
}
return operation(workspace);
});
}
async #resolve(target: WorkspaceTarget): Promise<ResolvedWorkspace> {
if (target.kind === 'host_path') {
const cwd = await canonicalHostDirectory(target.path);
return { target: { kind: 'host_path', path: cwd }, cwd, projectId: null };
}
const project = findProjectByIdentity(await this.catalog.list(), target.projectId);
if (!project) {
throw new WorkspaceResolutionError(
'not_found',
`Project does not exist: ${target.projectId}`,
);
}
if (project.archivedAt !== undefined) {
throw new WorkspaceResolutionError(
'operation_conflict',
`Project is archived: ${target.projectId}`,
);
}
if (!project.available || !project.preferredPath) {
throw new WorkspaceResolutionError(
'operation_conflict',
`Project is unavailable: ${target.projectId}`,
);
}
return {
target: { kind: 'project', projectId: project.id },
cwd: project.preferredPath,
projectId: project.id,
project,
};
}
}
async function canonicalHostDirectory(path: string): Promise<string> {
if (!isAbsolute(path)) {
throw new WorkspaceResolutionError('invalid_request', 'Workspace Host path must be absolute');
}
try {
const canonical = await realpath(resolve(path));
if ((await stat(canonical)).isDirectory()) return canonical;
} catch {
// Project a stable request error below.
}
throw new WorkspaceResolutionError(
'invalid_request',
'Workspace Host path is not an existing directory',
);
}