feat(coding): add Main Data Service Host adapter
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
refreshCodingProviderCredential,
|
||||
} from './coding-provider-auth';
|
||||
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
|
||||
import { createDataServiceOperations } from '../services/data-service-client';
|
||||
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
|
||||
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
|
||||
|
||||
@@ -205,8 +206,10 @@ export function createCodingComposition(
|
||||
productTools,
|
||||
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
|
||||
});
|
||||
const dataService = createDataServiceOperations({ projects });
|
||||
return {
|
||||
attachments,
|
||||
dataService,
|
||||
productTools,
|
||||
projects,
|
||||
conversations,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { CodingConversationService } from '../coding-runtime/conversation-service';
|
||||
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import type { DataServiceOperations } from '../services/data-service-client';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ export interface CodingProductHost {
|
||||
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
dataService: DataServiceOperations;
|
||||
productTools: PiProductTools;
|
||||
projects: CodingProjectService;
|
||||
conversations: CodingConversationService;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleLearningRoutes } from './routes/learning';
|
||||
import { handleDataServiceRoutes } from './routes/data-service';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
@@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleLearningRoutes,
|
||||
handleDataServiceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
|
||||
335
electron/api/routes/data-service.ts
Normal file
335
electron/api/routes/data-service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import {
|
||||
type DataServiceErrorContext,
|
||||
type DataServiceHostResult,
|
||||
} from '../../../shared/data-service';
|
||||
|
||||
const LOCAL_ROOT = '/api/works/data-service';
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
|
||||
class DataServiceRouteError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return value && value.length <= maximum ? value : null;
|
||||
}
|
||||
|
||||
function routeFailure<T>(
|
||||
status: number,
|
||||
code: string,
|
||||
error: string,
|
||||
retryable = false,
|
||||
extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {},
|
||||
): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
|
||||
...(extra.context === undefined ? {} : { context: extra.context }),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(503, 'data_service_unavailable', 'Data Service is temporarily unavailable', true);
|
||||
}
|
||||
|
||||
function methodNotAllowed<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(405, 'method_not_allowed', 'Data Service method is not allowed');
|
||||
}
|
||||
|
||||
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = req.headers['content-length'];
|
||||
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
|
||||
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > MAX_REQUEST_BYTES) {
|
||||
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.byteLength;
|
||||
if (size > MAX_REQUEST_BYTES) {
|
||||
throw new DataServiceRouteError(413, 'request_too_large', 'Data Service request is too large');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
if (!isRecord(value)) throw new Error('object required');
|
||||
return value;
|
||||
} catch {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
|
||||
const expected = new Set(keys);
|
||||
if (Object.keys(value).some((key) => !expected.has(key))
|
||||
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(value);
|
||||
} catch {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
if (decoded.length > maximum || !pattern.test(decoded)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parseCollection(value: string): string {
|
||||
return decodeSegment(value, 48, COLLECTION_PATTERN);
|
||||
}
|
||||
|
||||
function parseDocumentId(value: string): string {
|
||||
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
|
||||
if (documentId === '.' || documentId === '..') {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return documentId;
|
||||
}
|
||||
|
||||
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
|
||||
const accepted = new Set(allowed);
|
||||
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireConfirmed(url: URL): void {
|
||||
requireQueryKeys(url, ['confirmed']);
|
||||
if (url.searchParams.getAll('confirmed').length !== 1
|
||||
|| url.searchParams.get('confirmed') !== 'true') {
|
||||
throw new DataServiceRouteError(
|
||||
400,
|
||||
'confirmation_required',
|
||||
'Explicit confirmation is required for this Data Service operation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLimit(url: URL): number | undefined {
|
||||
const values = url.searchParams.getAll('limit');
|
||||
if (values.length === 0) return undefined;
|
||||
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
const limit = Number(values[0]);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function parseCursor(url: URL): string | undefined {
|
||||
const values = url.searchParams.getAll('cursor');
|
||||
if (values.length === 0) return undefined;
|
||||
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
|
||||
if (!cursor) throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseIfMatch(req: IncomingMessage): number | undefined {
|
||||
const value = req.headers['if-match'];
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
const revision = Number(value.slice(1, -1));
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new DataServiceRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
function parseCollections(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 20
|
||||
|| value.some((item) => typeof item !== 'string' || !COLLECTION_PATTERN.test(item))) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function parseData(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new DataServiceRouteError(422, 'invalid_request', 'Data Service request is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
// Host IPC and the browser fallback both consume the safe envelope. Keep the
|
||||
// transport status successful so a remote Data Service status cannot be
|
||||
// mistaken for a Host transport failure by the existing proxy.
|
||||
sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
function isDataServicePath(pathname: string): boolean {
|
||||
return pathname === LOCAL_ROOT || pathname.startsWith(`${LOCAL_ROOT}/`);
|
||||
}
|
||||
|
||||
export async function handleDataServiceRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isDataServicePath(url.pathname)) return false;
|
||||
|
||||
const operations = ctx.codingProducts?.dataService;
|
||||
if (!operations) {
|
||||
sendResult(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
const projectListPath = `${LOCAL_ROOT}/projects`;
|
||||
const projectPath = `${LOCAL_ROOT}/project`;
|
||||
if (url.pathname === projectListPath) {
|
||||
requireQueryKeys(url, []);
|
||||
if (method !== 'GET') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
sendResult(res, await operations.listProjects());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === projectPath) {
|
||||
if (method === 'PUT') {
|
||||
requireQueryKeys(url, []);
|
||||
const body = await readBoundedJson(req);
|
||||
requireExactKeys(body, ['collections']);
|
||||
sendResult(res, await operations.configure({ collections: parseCollections(body.collections) }));
|
||||
} else if (method === 'GET') {
|
||||
requireQueryKeys(url, []);
|
||||
sendResult(res, await operations.inspect());
|
||||
} else if (method === 'DELETE') {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.removeProject({ confirmed: true }));
|
||||
} else {
|
||||
sendResult(res, methodNotAllowed());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === `${projectPath}/reset`) {
|
||||
if (method !== 'POST') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.reset({ confirmed: true }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const collectionPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)$`),
|
||||
);
|
||||
if (collectionPath) {
|
||||
if (method !== 'DELETE') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireConfirmed(url);
|
||||
sendResult(res, await operations.removeCollection({
|
||||
collection: parseCollection(collectionPath[1]),
|
||||
confirmed: true,
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentCollectionPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents$`),
|
||||
);
|
||||
if (documentCollectionPath) {
|
||||
const collection = parseCollection(documentCollectionPath[1]);
|
||||
if (method !== 'GET') {
|
||||
sendResult(res, methodNotAllowed());
|
||||
} else {
|
||||
requireQueryKeys(url, ['limit', 'cursor']);
|
||||
sendResult(res, await operations.listDocuments({
|
||||
collection,
|
||||
limit: parseLimit(url),
|
||||
cursor: parseCursor(url),
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentPath = url.pathname.match(
|
||||
new RegExp(`^${LOCAL_ROOT}/project/collections/([^/]+)/documents/([^/]+)$`),
|
||||
);
|
||||
if (documentPath) {
|
||||
const collection = parseCollection(documentPath[1]);
|
||||
const documentId = parseDocumentId(documentPath[2]);
|
||||
if (method === 'GET') {
|
||||
requireQueryKeys(url, []);
|
||||
sendResult(res, await operations.getDocument({ collection, document_id: documentId }));
|
||||
} else if (method === 'PUT') {
|
||||
requireQueryKeys(url, []);
|
||||
const body = await readBoundedJson(req);
|
||||
requireExactKeys(body, ['data']);
|
||||
const ifRevision = parseIfMatch(req);
|
||||
sendResult(res, await operations.putDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
data: parseData(body.data),
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
}));
|
||||
} else if (method === 'DELETE') {
|
||||
requireConfirmed(url);
|
||||
const ifRevision = parseIfMatch(req);
|
||||
sendResult(res, await operations.deleteDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
confirmed: true,
|
||||
}));
|
||||
} else {
|
||||
sendResult(res, methodNotAllowed());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
sendResult(res, routeFailure(404, 'route_not_found', 'Data Service route was not found'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof DataServiceRouteError) {
|
||||
sendResult(res, routeFailure(error.status, error.code, error.message, error.retryable));
|
||||
return true;
|
||||
}
|
||||
sendResult(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDataServiceRoute = handleDataServiceRoutes;
|
||||
Reference in New Issue
Block a user