Thomas Zhang 3 месяцев назад
Родитель
Сommit
24ae8dfc3e
35 измененных файлов с 2963 добавлено и 12 удалено
  1. 794 9
      package-lock.json
  2. 5 1
      package.json
  3. 14 0
      src/lib/server/catalog/agents/git.ts
  4. 2 0
      src/lib/server/catalog/agents/index.ts
  5. 13 0
      src/lib/server/catalog/agents/types.ts
  6. 65 0
      src/lib/server/catalog/catalog.ts
  7. 14 0
      src/lib/server/catalog/documents/git.ts
  8. 2 0
      src/lib/server/catalog/documents/index.ts
  9. 13 0
      src/lib/server/catalog/documents/types.ts
  10. 176 0
      src/lib/server/catalog/git.ts
  11. 2 0
      src/lib/server/catalog/index.ts
  12. 14 0
      src/lib/server/catalog/kbs/git.ts
  13. 2 0
      src/lib/server/catalog/kbs/index.ts
  14. 13 0
      src/lib/server/catalog/kbs/types.ts
  15. 30 0
      src/lib/server/catalog/models/git.ts
  16. 2 0
      src/lib/server/catalog/models/index.ts
  17. 13 0
      src/lib/server/catalog/models/types.ts
  18. 30 0
      src/lib/server/catalog/tools/git.ts
  19. 2 0
      src/lib/server/catalog/tools/index.ts
  20. 13 0
      src/lib/server/catalog/tools/types.ts
  21. 187 0
      src/lib/server/git/cache.ts
  22. 245 0
      src/lib/server/git/github.ts
  23. 3 0
      src/lib/server/git/index.ts
  24. 312 0
      src/lib/server/git/local.ts
  25. 41 0
      src/lib/server/git/types.ts
  26. 2 1
      src/lib/server/logger.ts
  27. 15 0
      src/lib/server/server.ts
  28. 114 0
      src/lib/types/custom-resources/agent.ts
  29. 5 0
      src/lib/types/custom-resources/index.ts
  30. 233 0
      src/lib/types/custom-resources/kb.ts
  31. 246 0
      src/lib/types/custom-resources/model.ts
  32. 128 0
      src/lib/types/custom-resources/tool.ts
  33. 154 0
      src/lib/types/custom-resources/types.ts
  34. 38 1
      src/lib/types/workspace.ts
  35. 21 0
      src/lib/yaml.ts

Разница между файлами не показана из-за своего большого размера
+ 794 - 9
package-lock.json


+ 5 - 1
package.json

@@ -22,7 +22,9 @@
 		"@eslint/js": "^9.39.2",
 		"@eslint/js": "^9.39.2",
 		"@fontsource-variable/inter": "^5.2.8",
 		"@fontsource-variable/inter": "^5.2.8",
 		"@internationalized/date": "^3.12.0",
 		"@internationalized/date": "^3.12.0",
+		"@kubernetes/client-node": "^1.4.0",
 		"@lucide/svelte": "^1.7.0",
 		"@lucide/svelte": "^1.7.0",
+		"@octokit/rest": "^22.0.1",
 		"@playwright/test": "^1.58.2",
 		"@playwright/test": "^1.58.2",
 		"@sveltejs/adapter-auto": "^7.0.0",
 		"@sveltejs/adapter-auto": "^7.0.0",
 		"@sveltejs/kit": "^2.50.2",
 		"@sveltejs/kit": "^2.50.2",
@@ -42,6 +44,7 @@
 		"eslint-plugin-svelte": "^3.14.0",
 		"eslint-plugin-svelte": "^3.14.0",
 		"formsnap": "^2.0.1",
 		"formsnap": "^2.0.1",
 		"globals": "^17.3.0",
 		"globals": "^17.3.0",
+		"isomorphic-git": "^1.38.1",
 		"jose": "^6.2.3",
 		"jose": "^6.2.3",
 		"layerchart": "^2.0.0-next.48",
 		"layerchart": "^2.0.0-next.48",
 		"mode-watcher": "^1.1.0",
 		"mode-watcher": "^1.1.0",
@@ -66,6 +69,7 @@
 		"vite": "^7.3.1",
 		"vite": "^7.3.1",
 		"vitest": "^4.1.0",
 		"vitest": "^4.1.0",
 		"vitest-browser-svelte": "^2.0.2",
 		"vitest-browser-svelte": "^2.0.2",
-		"winston": "^3.19.0"
+		"winston": "^3.19.0",
+		"yaml": "^2.9.0"
 	}
 	}
 }
 }

+ 14 - 0
src/lib/server/catalog/agents/git.ts

@@ -0,0 +1,14 @@
+import { GitCatalog } from '$lib/server/catalog/git';
+import type { GitClient } from '$lib/server/git';
+import { type AgentCR, fromAgentCR, toAgentCR } from '$lib/types/custom-resources';
+import type { Agent } from '$lib/types/entities';
+
+import type { AgentCatalog } from './types';
+
+class GitAgentCatalog extends GitCatalog<Agent, AgentCR> implements AgentCatalog {
+	constructor(gitClient: GitClient, directory = 'agents', branch = 'main') {
+		super(gitClient, directory, 'agent', fromAgentCR, toAgentCR, branch);
+	}
+}
+
+export { GitAgentCatalog };

+ 2 - 0
src/lib/server/catalog/agents/index.ts

@@ -0,0 +1,2 @@
+export { GitAgentCatalog } from './git';
+export type { AgentCatalog } from './types';

+ 13 - 0
src/lib/server/catalog/agents/types.ts

@@ -0,0 +1,13 @@
+import type { AgentCR } from '$lib/types/custom-resources';
+import type { Agent } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface AgentCatalog {
+	list(): Promise<Agent[]>;
+	listChanges(id: string): Promise<Changes>;
+	create(agent: Agent): Promise<{ entity: Agent; cr: AgentCR }>;
+	update(agent: Agent): Promise<{ entity: Agent; cr: AgentCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { AgentCatalog };

+ 65 - 0
src/lib/server/catalog/catalog.ts

@@ -0,0 +1,65 @@
+import { NotFoundError } from '$lib/server/errors';
+import { type GitClient, LocalClient } from '$lib/server/git';
+import type { Workspace } from '$lib/types/workspace';
+
+import { type AgentCatalog, GitAgentCatalog } from './agents';
+import { type DocumentCatalog, GitDocumentCatalog } from './documents';
+import { GitKBCatalog, type KBCatalog } from './kbs';
+import { GitModelCatalog, type ModelCatalog } from './models';
+import { GitToolCatalog, type ToolCatalog } from './tools';
+
+type WorkspaceCatalog = {
+	models: ModelCatalog;
+	tools: ToolCatalog;
+	kbs: KBCatalog;
+	documents: DocumentCatalog;
+	agents: AgentCatalog;
+};
+
+class Catalog {
+	#workspaces: Map<
+		string,
+		{
+			workspace: Workspace;
+			gitClient: GitClient;
+			catalog: WorkspaceCatalog;
+		}
+	> = new Map();
+
+	constructor(workspaces: Workspace[]) {
+		for (const workspace of workspaces) {
+			// TODO: github support
+			if (workspace.persistence.type === 'local') {
+				const gitClient = new LocalClient({
+					directory: workspace.persistence.directory,
+					authorEmail: workspace.persistence.authorEmail,
+					authorName: workspace.persistence.authorName,
+				});
+				this.#workspaces.set(workspace.id, {
+					workspace,
+					gitClient,
+					catalog: {
+						models: new GitModelCatalog(gitClient),
+						tools: new GitToolCatalog(gitClient),
+						kbs: new GitKBCatalog(gitClient),
+						documents: new GitDocumentCatalog(gitClient),
+						agents: new GitAgentCatalog(gitClient),
+					},
+				});
+			}
+		}
+	}
+
+	async init(): Promise<void> {}
+
+	async shutdown(): Promise<void> {}
+
+	getWorkspaceCatalog(workspaceId: string): WorkspaceCatalog {
+		const workspace = this.#workspaces.get(workspaceId);
+		if (!workspace) throw new NotFoundError(`Workspace <${workspaceId}> not found`);
+		return workspace.catalog;
+	}
+}
+
+export type { WorkspaceCatalog };
+export { Catalog };

+ 14 - 0
src/lib/server/catalog/documents/git.ts

@@ -0,0 +1,14 @@
+import { GitCatalog } from '$lib/server/catalog/git';
+import type { GitClient } from '$lib/server/git';
+import { type DocumentCR, fromDocumentCR, toDocumentCR } from '$lib/types/custom-resources';
+import type { KBDocument } from '$lib/types/entities';
+
+import type { DocumentCatalog } from './types';
+
+class GitDocumentCatalog extends GitCatalog<KBDocument, DocumentCR> implements DocumentCatalog {
+	constructor(gitClient: GitClient, directory = 'documents', branch = 'main') {
+		super(gitClient, directory, 'document', fromDocumentCR, toDocumentCR, branch);
+	}
+}
+
+export { GitDocumentCatalog };

+ 2 - 0
src/lib/server/catalog/documents/index.ts

@@ -0,0 +1,2 @@
+export { GitDocumentCatalog } from './git';
+export type { DocumentCatalog } from './types';

+ 13 - 0
src/lib/server/catalog/documents/types.ts

@@ -0,0 +1,13 @@
+import type { DocumentCR } from '$lib/types/custom-resources';
+import type { KBDocument } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface DocumentCatalog {
+	list(): Promise<KBDocument[]>;
+	listChanges(id: string): Promise<Changes>;
+	create(document: KBDocument): Promise<{ entity: KBDocument; cr: DocumentCR }>;
+	update(document: KBDocument): Promise<{ entity: KBDocument; cr: DocumentCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { DocumentCatalog };

+ 176 - 0
src/lib/server/catalog/git.ts

@@ -0,0 +1,176 @@
+import type { GitClient } from '$lib/server/git';
+import { slugify } from '$lib/types/custom-resources';
+import type { Changes } from '$lib/types/workspace';
+import * as yaml from '$lib/yaml';
+
+interface Entity {
+	id: string;
+	name: string;
+}
+
+abstract class GitCatalog<T extends Entity, TCR = unknown> {
+	#gitClient: GitClient;
+	#entities: T[] = [];
+	#entitiesDirectory: string;
+	#entityTypeName: string;
+	#branch: string;
+	#entityParser: (yamlContent: TCR) => T;
+	#entitySerializer: (entity: T) => TCR;
+	#initialized: boolean = false;
+
+	constructor(
+		gitClient: GitClient,
+		entitiesDirectory: string,
+		entityTypeName: string,
+		entityParser: (yamlContent: TCR) => T,
+		entitySerializer: (entity: T) => TCR,
+		branch = 'main',
+	) {
+		this.#gitClient = gitClient;
+		this.#entitiesDirectory = entitiesDirectory;
+		this.#entityTypeName = entityTypeName;
+		this.#branch = branch;
+		this.#entityParser = entityParser;
+		this.#entitySerializer = entitySerializer;
+	}
+
+	async #init(): Promise<void> {
+		if (this.#initialized) {
+			return;
+		}
+		await this.#loadEntities();
+		this.#gitClient.onFileChange(async (filePath, branch) => {
+			if (branch !== this.#branch) {
+				return;
+			}
+			if (!filePath.startsWith(this.#entitiesDirectory)) {
+				return;
+			}
+			await this.#loadEntities();
+		});
+		this.#initialized = true;
+	}
+
+	async #loadEntities(): Promise<void> {
+		const entries = await this.#gitClient.readRepoTree(this.#entitiesDirectory, this.#branch);
+		const entities: T[] = [];
+		for (const entry of entries) {
+			if (entry.type !== 'blob') continue;
+			try {
+				const content = await this.#gitClient.readFile(entry.path, this.#branch);
+				const yamlContent = yaml.parse<TCR>(content.content);
+				const entity = this.#entityParser(yamlContent);
+				entities.push(entity);
+			} catch (err) {
+				console.error(`Failed to load entity from ${entry.path}:`, err);
+			}
+		}
+		this.#entities = entities;
+	}
+
+	async list(): Promise<T[]> {
+		await this.#init();
+		return this.#entities;
+	}
+
+	async listChanges(entityId: string): Promise<Changes> {
+		const filePath = `${this.#entitiesDirectory}/${entityId}.yaml`;
+		const file = await this.#gitClient.readFile(filePath, this.#branch);
+		const [prs, gitCommits] = await Promise.all([
+			this.#gitClient.listPRs('all'),
+			this.#gitClient.listCommits(file, this.#branch),
+		]);
+		const entityPRs = prs.filter((pr) => pr.title.startsWith(`[entity] ${entityId}`));
+		return { gitPullRequests: entityPRs, gitCommits };
+	}
+
+	async create(entity: T): Promise<{ entity: T; cr: TCR }> {
+		await this.#init();
+		const branch = `feat/create-${this.#entityTypeName}-${entity.name}`;
+		entity.id = slugify(entity.name);
+		const cr = this.#entitySerializer(entity);
+		await this.#gitClient.createFile(
+			`${this.#entitiesDirectory}/${entity.id}.yaml`,
+			yaml.stringify(cr),
+			branch,
+			`Create ${this.#entityTypeName} ${entity.name}`,
+		);
+		const pr = await this.#gitClient.createPR(
+			`Create ${this.#entityTypeName} ${entity.name}`,
+			branch,
+		);
+		if (pr.merged) {
+			// if auto-merge is enabled, the PR is already merged, we can add the entity to catalog immediately.
+			this.#entities.push(entity);
+		}
+		return { entity, cr };
+	}
+
+	async update(entity: T): Promise<{ entity: T; cr: TCR }> {
+		const existing = this.#entities.find((m) => m.id === entity.id);
+		if (!existing) {
+			throw new Error(`entity not found: ${entity.id}`);
+		}
+		entity = { ...existing, ...entity, id: entity.id } as T;
+		const cr = this.#entitySerializer(entity);
+		const branch = `feat/update-${this.#entityTypeName}-${entity.id}`;
+		const filePath = `${this.#entitiesDirectory}/${entity.id}.yaml`;
+		const file = await this.#gitClient.readFile(filePath, this.#branch);
+		await this.#gitClient.updateFile(
+			file,
+			yaml.stringify(cr),
+			branch,
+			`Update ${this.#entityTypeName} ${existing.name}`,
+		);
+		const pr = await this.#gitClient.createPR(
+			`Update ${this.#entityTypeName} ${existing.name}`,
+			branch,
+		);
+		if (pr.merged) {
+			this.#entities = this.#entities.map((m) => (m.id === entity.id ? entity : m));
+		}
+		return { entity, cr };
+	}
+
+	async delete(id: string): Promise<void> {
+		await this.#init();
+		const existing = this.#entities.find((m) => m.id === id);
+		if (!existing) {
+			throw new Error(`entity not found: ${id}`);
+		}
+		const branch = `feat/delete-${this.#entityTypeName}-${id}`;
+		const filePath = `${this.#entitiesDirectory}/${id}.yaml`;
+		const file = await this.#gitClient.readFile(filePath, this.#branch);
+		await this.#gitClient.deleteFile(
+			file,
+			branch,
+			`Delete ${this.#entityTypeName} ${existing.name}`,
+		);
+		const pr = await this.#gitClient.createPR(
+			`Delete ${this.#entityTypeName} ${existing.name}`,
+			branch,
+		);
+		if (pr.merged) {
+			this.#entities = this.#entities.filter((m) => m.id !== id);
+		}
+	}
+
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	protected async listFromDir<U>(directory: string, parser: (yaml: any) => U): Promise<U[]> {
+		const entries = await this.#gitClient.readRepoTree(directory, this.#branch);
+		const result: U[] = [];
+		for (const entry of entries) {
+			if (entry.type !== 'blob') continue;
+			try {
+				const content = await this.#gitClient.readFile(entry.path, this.#branch);
+				const yamlContent = yaml.parse(content.content);
+				result.push(parser(yamlContent));
+			} catch (err) {
+				console.error(`Failed to load entity from ${entry.path}:`, err);
+			}
+		}
+		return result;
+	}
+}
+
+export { GitCatalog };

+ 2 - 0
src/lib/server/catalog/index.ts

@@ -0,0 +1,2 @@
+export type { WorkspaceCatalog } from './catalog';
+export { Catalog } from './catalog';

+ 14 - 0
src/lib/server/catalog/kbs/git.ts

@@ -0,0 +1,14 @@
+import { GitCatalog } from '$lib/server/catalog/git';
+import type { GitClient } from '$lib/server/git';
+import { fromManagedKBCR, type ManagedKBCR, toManagedKBCR } from '$lib/types/custom-resources';
+import type { ManagedKB } from '$lib/types/entities';
+
+import type { KBCatalog } from './types';
+
+class GitKBCatalog extends GitCatalog<ManagedKB, ManagedKBCR> implements KBCatalog {
+	constructor(gitClient: GitClient, directory = 'kbs', branch = 'main') {
+		super(gitClient, directory, 'kb', fromManagedKBCR, toManagedKBCR, branch);
+	}
+}
+
+export { GitKBCatalog };

+ 2 - 0
src/lib/server/catalog/kbs/index.ts

@@ -0,0 +1,2 @@
+export { GitKBCatalog } from './git';
+export type { KBCatalog } from './types';

+ 13 - 0
src/lib/server/catalog/kbs/types.ts

@@ -0,0 +1,13 @@
+import type { ManagedKBCR } from '$lib/types/custom-resources';
+import type { ManagedKB } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface KBCatalog {
+	list(): Promise<ManagedKB[]>;
+	listChanges(id: string): Promise<Changes>;
+	create(kb: ManagedKB): Promise<{ entity: ManagedKB; cr: ManagedKBCR }>;
+	update(kb: ManagedKB): Promise<{ entity: ManagedKB; cr: ManagedKBCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { KBCatalog };

+ 30 - 0
src/lib/server/catalog/models/git.ts

@@ -0,0 +1,30 @@
+import { GitCatalog } from '$lib/server/catalog/git';
+import type { GitClient } from '$lib/server/git';
+import {
+	type ExternalModelCR,
+	fromModelCR,
+	type ManagedModelCR,
+	toExternalModelCR,
+	toManagedModelCR,
+} from '$lib/types/custom-resources';
+import type { Model } from '$lib/types/entities';
+
+import type { ModelCatalog } from './types';
+
+class GitModelCatalog
+	extends GitCatalog<Model, ManagedModelCR | ExternalModelCR>
+	implements ModelCatalog
+{
+	constructor(gitClient: GitClient, directory = 'models', branch = 'main') {
+		super(
+			gitClient,
+			directory,
+			'model',
+			fromModelCR,
+			(model) => (model.mode === 'managed' ? toManagedModelCR(model) : toExternalModelCR(model)),
+			branch,
+		);
+	}
+}
+
+export { GitModelCatalog };

+ 2 - 0
src/lib/server/catalog/models/index.ts

@@ -0,0 +1,2 @@
+export { GitModelCatalog } from './git';
+export type { ModelCatalog } from './types';

+ 13 - 0
src/lib/server/catalog/models/types.ts

@@ -0,0 +1,13 @@
+import type { ExternalModelCR, ManagedModelCR } from '$lib/types/custom-resources';
+import type { Model } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface ModelCatalog {
+	list(): Promise<Model[]>;
+	listChanges(modelId: string): Promise<Changes>;
+	create(model: Model): Promise<{ entity: Model; cr: ManagedModelCR | ExternalModelCR }>;
+	update(model: Model): Promise<{ entity: Model; cr: ManagedModelCR | ExternalModelCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { ModelCatalog };

+ 30 - 0
src/lib/server/catalog/tools/git.ts

@@ -0,0 +1,30 @@
+import { GitCatalog } from '$lib/server/catalog/git';
+import type { GitClient } from '$lib/server/git';
+import {
+	type ExternalToolCR,
+	fromToolCR,
+	type ManagedToolCR,
+	toExternalToolCR,
+	toManagedToolCR,
+} from '$lib/types/custom-resources';
+import type { Tool } from '$lib/types/entities';
+
+import type { ToolCatalog } from './types';
+
+class GitToolCatalog
+	extends GitCatalog<Tool, ExternalToolCR | ManagedToolCR>
+	implements ToolCatalog
+{
+	constructor(gitClient: GitClient, directory = 'tools', branch = 'main') {
+		super(
+			gitClient,
+			directory,
+			'tool',
+			fromToolCR,
+			(tool) => (tool.mode === 'managed' ? toManagedToolCR(tool) : toExternalToolCR(tool)),
+			branch,
+		);
+	}
+}
+
+export { GitToolCatalog };

+ 2 - 0
src/lib/server/catalog/tools/index.ts

@@ -0,0 +1,2 @@
+export { GitToolCatalog } from './git';
+export type { ToolCatalog } from './types';

+ 13 - 0
src/lib/server/catalog/tools/types.ts

@@ -0,0 +1,13 @@
+import type { ExternalToolCR, ManagedToolCR } from '$lib/types/custom-resources';
+import type { Tool } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface ToolCatalog {
+	list(): Promise<Tool[]>;
+	listChanges(toolId: string): Promise<Changes>;
+	create(tool: Tool): Promise<{ entity: Tool; cr: ManagedToolCR | ExternalToolCR }>;
+	update(tool: Tool): Promise<{ entity: Tool; cr: ManagedToolCR | ExternalToolCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { ToolCatalog };

+ 187 - 0
src/lib/server/git/cache.ts

@@ -0,0 +1,187 @@
+/**
+ * BranchCache — in-memory snapshot of a Git branch's file tree.
+ *
+ * Downloads the repository as a gzipped tar archive (single HTTP request),
+ * decompresses and parses it, and stores all file contents keyed by path.
+ * Subsequent reads are served entirely from memory.
+ *
+ * Refresh strategy: stale-while-revalidate (TTL-based). A background refresh
+ * is triggered on first access after the TTL elapses; concurrent accesses
+ * during a refresh see the previous snapshot.
+ */
+import { createHash } from 'node:crypto';
+import { promisify } from 'node:util';
+import { gunzip } from 'node:zlib';
+
+import type { GitFile, GitTreeEntry } from '$lib/server/git/types';
+import { gitLogger as log } from '$lib/server/logger';
+
+const gunzipAsync = promisify(gunzip);
+
+// --- Tar parser ---
+
+type TarEntry = { path: string; content: Buffer };
+
+/**
+ * Minimal UStar/POSIX tar parser operating on a flat Buffer.
+ * Handles regular files only; skips directories and other entry types.
+ * Strips the leading top-level directory component that archive tools inject.
+ */
+const parseTar = (buf: Buffer): TarEntry[] => {
+	const entries: TarEntry[] = [];
+	let offset = 0;
+
+	while (offset + 512 <= buf.length) {
+		const header = buf.subarray(offset, offset + 512);
+		// Two consecutive zero blocks mark end of archive.
+		if (header.every((b) => b === 0)) break;
+
+		offset += 512;
+
+		const rawName = header.subarray(0, 100).toString('utf-8').replace(/\0/g, '');
+		const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0/g, '');
+		const fullName = prefix ? `${prefix}/${rawName}` : rawName;
+		// Strip the injected top-level directory (e.g. "owner-repo-sha123/").
+		const path = fullName.replace(/^[^/]+\//, '');
+
+		const sizeStr = header.subarray(124, 136).toString('utf-8').replace(/\0/g, '').trim();
+		const size = parseInt(sizeStr, 8) || 0;
+
+		const typeflag = String.fromCharCode(header[156]);
+		const isFile = typeflag === '0' || typeflag === '\0';
+
+		if (isFile && path) {
+			entries.push({ path, content: buf.subarray(offset, offset + size) });
+		}
+
+		// Advance past data blocks (rounded up to 512-byte boundary).
+		offset += Math.ceil(size / 512) * 512;
+	}
+
+	return entries;
+};
+
+// --- BranchCache ---
+
+const DEFAULT_TTL_MS = 60_000;
+
+type DownloadArchive = (branch: string) => Promise<ReadableStream<Uint8Array>>;
+
+const gitBlobSha = (content: Buffer): string => {
+	return createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
+};
+
+const buildTree = (files: GitFile[]): GitTreeEntry[] => {
+	const tree = new Map<string, GitTreeEntry>();
+
+	for (const file of files) {
+		const parts = file.path.split('/');
+		for (let index = 1; index < parts.length; index += 1) {
+			const dirPath = parts.slice(0, index).join('/');
+			if (!tree.has(dirPath)) {
+				tree.set(dirPath, { path: dirPath, type: 'tree', sha: '' });
+			}
+		}
+
+		tree.set(file.path, {
+			path: file.path,
+			type: 'blob',
+			sha: file.sha,
+			size: file.size,
+		});
+	}
+
+	return [...tree.values()].sort((left, right) => left.path.localeCompare(right.path));
+};
+
+class BranchCache {
+	readonly #downloadArchive: DownloadArchive;
+	readonly #branch: string;
+	readonly #ttlMs: number;
+
+	#files = new Map<string, GitFile>();
+	#tree: GitTreeEntry[] = [];
+	#fetchedAt: number | null = null;
+	#refreshing: Promise<void> | null = null;
+
+	constructor(downloadArchive: DownloadArchive, branch: string, ttlMs = DEFAULT_TTL_MS) {
+		this.#downloadArchive = downloadArchive;
+		this.#branch = branch;
+		this.#ttlMs = ttlMs;
+	}
+
+	get isStale(): boolean {
+		return this.#fetchedAt === null || Date.now() - this.#fetchedAt > this.#ttlMs;
+	}
+
+	/** Read a file by path. Returns undefined if not found. Refreshes cache if stale. */
+	async readFile(path: string): Promise<GitFile | undefined> {
+		await this.#ensureFresh();
+		return this.#files.get(path);
+	}
+
+	/** List all tree entries. Refreshes cache if stale. */
+	async readTree(): Promise<GitTreeEntry[]> {
+		await this.#ensureFresh();
+		return this.#tree;
+	}
+
+	/** Force-expire the cache so the next access triggers a refresh. */
+	invalidate(): void {
+		this.#fetchedAt = null;
+		log.debug('Branch cache invalidated', { branch: this.#branch });
+	}
+
+	async #ensureFresh(): Promise<void> {
+		if (!this.isStale) return;
+		// Deduplicate concurrent refresh calls: return the same in-flight promise.
+		if (!this.#refreshing) {
+			this.#refreshing = this.#refresh().finally(() => {
+				this.#refreshing = null;
+			});
+		}
+		await this.#refreshing;
+	}
+
+	async #refresh(): Promise<void> {
+		log.info('Refreshing branch cache', { branch: this.#branch });
+		const start = Date.now();
+
+		const webStream = await this.#downloadArchive(this.#branch);
+		const reader = webStream.getReader();
+		const chunks: Uint8Array[] = [];
+		for (;;) {
+			const { done, value } = await reader.read();
+			if (done) break;
+			if (value) chunks.push(value);
+		}
+		const compressed = Buffer.concat(chunks);
+		const decompressed = await gunzipAsync(compressed);
+		const entries = parseTar(decompressed);
+
+		const newFiles = new Map<string, GitFile>();
+		for (const { path, content } of entries) {
+			newFiles.set(path, {
+				name: path.split('/').pop() ?? path,
+				path,
+				sha: gitBlobSha(content),
+				size: content.length,
+				content: content.toString('base64'),
+			});
+		}
+
+		const newTree = buildTree([...newFiles.values()]);
+
+		this.#files = newFiles;
+		this.#tree = newTree;
+		this.#fetchedAt = Date.now();
+
+		log.info('Branch cache refreshed', {
+			branch: this.#branch,
+			files: newFiles.size,
+			ms: Date.now() - start,
+		});
+	}
+}
+
+export { BranchCache };

+ 245 - 0
src/lib/server/git/github.ts

@@ -0,0 +1,245 @@
+/**
+ * GitHub implementation of GitClient using @octokit/rest.
+ *
+ * Supports github.com and GitHub Enterprise Server (via baseUrl override).
+ *
+ * Config:
+ *   token   — personal access token or GitHub App token with repo scope
+ *   owner   — repository owner (user or org)
+ *   repo    — repository name
+ *   baseUrl — API base URL; defaults to https://api.github.com
+ *             (GitHub Enterprise: https://hostname/api/v3)
+ */
+import { Octokit } from '@octokit/rest';
+
+import { BranchCache } from '$lib/server/git/cache';
+import type { GitClient, GitFile, GitTreeEntry } from '$lib/server/git/types';
+import { gitLogger as log } from '$lib/server/logger';
+import type { GitCommit, GitPullRequest } from '$lib/types/workspace';
+
+type GitHubClientConfig = {
+	token: string;
+	owner: string;
+	repo: string;
+	baseUrl?: string;
+};
+
+class GitHubClient implements GitClient {
+	readonly #cfg: Required<GitHubClientConfig>;
+	readonly #octokit: Octokit;
+	readonly #branchCaches = new Map<string, BranchCache>();
+
+	constructor(cfg: GitHubClientConfig) {
+		this.#cfg = { baseUrl: 'https://api.github.com', ...cfg };
+		this.#octokit = new Octokit({
+			auth: cfg.token,
+			baseUrl: this.#cfg.baseUrl,
+			log: {
+				debug: (msg: string) => log.debug(msg),
+				info: (msg: string) => log.info(msg),
+				warn: (msg: string) => log.warn(msg),
+				error: (msg: string) => log.error(msg),
+			},
+		});
+	}
+
+	#getBranchCache(branch: string): BranchCache {
+		let cache = this.#branchCaches.get(branch);
+		if (!cache) {
+			cache = new BranchCache((cacheBranch) => this.#downloadArchive(cacheBranch), branch);
+			this.#branchCaches.set(branch, cache);
+		}
+		return cache;
+	}
+
+	#filterTree(entries: GitTreeEntry[], dir: string): GitTreeEntry[] {
+		const normalizedDir = dir === '.' ? '' : dir.replace(/^\/+|\/+$/g, '');
+		if (normalizedDir === '') {
+			return entries;
+		}
+		return entries.filter(
+			(entry) => entry.path === normalizedDir || entry.path.startsWith(`${normalizedDir}/`),
+		);
+	}
+
+	async readFile(path: string, branch: string): Promise<GitFile> {
+		const file = await this.#getBranchCache(branch).readFile(path);
+		if (!file) {
+			throw new Error(`File not found: ${path}`);
+		}
+		return file;
+	}
+
+	async createFile(
+		path: string,
+		content: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile> {
+		await this.#octokit.rest.repos.createOrUpdateFileContents({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			path,
+			branch,
+			message: commitMessage,
+			content: Buffer.from(content, 'utf8').toString('base64'),
+		});
+		this.#getBranchCache(branch).invalidate();
+		return this.readFile(path, branch);
+	}
+
+	async updateFile(
+		file: GitFile,
+		newContent: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile> {
+		await this.#octokit.rest.repos.createOrUpdateFileContents({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			path: file.path,
+			branch,
+			message: commitMessage,
+			sha: file.sha,
+			content: Buffer.from(newContent, 'utf8').toString('base64'),
+		});
+		this.#getBranchCache(branch).invalidate();
+		return this.readFile(file.path, branch);
+	}
+
+	// GitHub embeds \n in base64 content; strip before decoding.
+
+	async deleteFile(file: GitFile, branch: string, commitMessage: string): Promise<void> {
+		await this.#octokit.rest.repos.deleteFile({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			path: file.path,
+			branch,
+			message: commitMessage,
+			sha: file.sha,
+		});
+		this.#getBranchCache(branch).invalidate();
+	}
+
+	decodeFileContent(file: GitFile): string {
+		return Buffer.from(file.content.replace(/\n/g, ''), 'base64').toString('utf-8');
+	}
+
+	async readRepoTree(dir: string, branch: string): Promise<GitTreeEntry[]> {
+		const entries = await this.#getBranchCache(branch).readTree();
+		return this.#filterTree(entries, dir);
+	}
+
+	async listPRs(state: 'open' | 'closed' | 'all' = 'open'): Promise<GitPullRequest[]> {
+		const { data } = await this.#octokit.rest.pulls.list({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			state,
+			per_page: 50,
+		});
+		return data.map(mapPR);
+	}
+
+	async readPR(number: number): Promise<GitPullRequest> {
+		const { data } = await this.#octokit.rest.pulls.get({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			pull_number: number,
+		});
+		return mapPR(data);
+	}
+
+	async createPR(
+		title: string,
+		branch: string,
+		base?: string,
+		body?: string,
+	): Promise<GitPullRequest> {
+		const { data } = await this.#octokit.rest.pulls.create({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			title,
+			body,
+			head: branch,
+			base: base ?? (await this.getDefaultBranch()),
+		});
+		return mapPR(data);
+	}
+
+	async listCommits(file: GitFile, branch: string): Promise<GitCommit[]> {
+		const { data } = await this.#octokit.rest.repos.listCommits({
+			owner: this.#cfg.owner,
+			repo: this.#cfg.repo,
+			file: file.path,
+			sha: branch,
+			per_page: 50,
+		});
+		return data.map((commit) => ({
+			sha: commit.sha,
+			author: commit.author?.login || 'Unknown',
+			message: commit.commit.message,
+			createdAt: commit.commit.author?.date ? new Date(commit.commit.author.date) : undefined,
+		}));
+	}
+
+	async getDefaultBranch(): Promise<string> {
+		return this.#octokit.rest.repos
+			.get({
+				owner: this.#cfg.owner,
+				repo: this.#cfg.repo,
+			})
+			.then(({ data }) => data.default_branch);
+	}
+
+	#archiveUrl(branch: string): string {
+		return `${this.#cfg.baseUrl}/repos/${this.#cfg.owner}/${this.#cfg.repo}/tarball/${encodeURIComponent(branch)}`;
+	}
+
+	async #downloadArchive(branch: string): Promise<ReadableStream<Uint8Array>> {
+		const url = this.#archiveUrl(branch);
+		log.debug('Downloading archive', { url });
+		// Use fetch directly for streaming; GitHub returns a redirect to a pre-signed S3 URL.
+		const res = await fetch(url, {
+			headers: {
+				Authorization: `Bearer ${this.#cfg.token}`,
+				Accept: 'application/vnd.github.v3.tarball',
+			},
+		});
+		if (!res.ok) throw new Error(`GitHub ${res.status}: ${await res.text()}`);
+		return res.body!;
+	}
+}
+
+// Octokit PR types differ slightly between list and get responses, so we accept a common shape.
+type PRLike = {
+	id: number;
+	number: number;
+	title: string;
+	body: string | null;
+	state: string;
+	html_url: string;
+	head: { ref: string; sha: string };
+	base: { ref: string; sha: string };
+	merged?: boolean | null;
+	merged_at: string | null;
+	created_at: string;
+	updated_at: string;
+};
+
+const mapPR = (pr: PRLike): GitPullRequest => ({
+	id: pr.id,
+	number: pr.number,
+	title: pr.title,
+	body: pr.body ?? '',
+	state: pr.state === 'closed' ? 'closed' : 'open',
+	htmlUrl: pr.html_url,
+	head: { ref: pr.head.ref, sha: pr.head.sha },
+	base: { ref: pr.base.ref, sha: pr.base.sha },
+	merged: pr.merged ?? false,
+	mergedAt: pr.merged_at ? new Date(pr.merged_at) : undefined,
+	createdAt: pr.created_at ? new Date(pr.created_at) : undefined,
+	updatedAt: pr.updated_at ? new Date(pr.updated_at) : undefined,
+});
+
+export type { GitHubClientConfig };
+export { GitHubClient };

+ 3 - 0
src/lib/server/git/index.ts

@@ -0,0 +1,3 @@
+export { GitHubClient } from '$lib/server/git/github';
+export { LocalClient } from '$lib/server/git/local';
+export type { GitClient, GitFile, GitTreeEntry } from '$lib/server/git/types';

+ 312 - 0
src/lib/server/git/local.ts

@@ -0,0 +1,312 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+import git from 'isomorphic-git';
+
+import type { GitCommit, GitPullRequest } from '$lib/types/workspace';
+
+import type { GitClient, GitFile, GitTreeEntry } from './types';
+
+type LocalClientConfig = {
+	directory: string;
+	authorEmail: string;
+	authorName: string;
+};
+
+// branch will be ignored for now since we only support a single branch (e.g. "main") in the local filesystem
+class LocalClient implements GitClient {
+	readonly #cfg: LocalClientConfig;
+	#queue: Promise<void> = Promise.resolve();
+	#fileEventTimers: Map<string, NodeJS.Timeout> = new Map();
+	#fileWatcherAbortController: AbortController = new AbortController();
+	#fileChangeHandlers: ((filePath: string, branch: string) => void)[] = [];
+
+	constructor(readonly cfg: LocalClientConfig) {
+		this.#cfg = cfg;
+	}
+
+	#serialize<T>(operation: () => Promise<T>): Promise<T> {
+		const next = this.#queue.then(operation, operation);
+		this.#queue = next.then(
+			() => undefined,
+			() => undefined,
+		);
+		return next;
+	}
+
+	#getFilePath(filePath: string): string {
+		return path.join(this.#cfg.directory, filePath);
+	}
+
+	async #readWorkingTreeFile(filePath: string): Promise<GitFile> {
+		const fullPath = this.#getFilePath(filePath);
+		const content = await fs.readFile(fullPath, 'utf8');
+		const { oid } = await git.hashBlob({ object: Buffer.from(content, 'utf8') });
+		return {
+			name: path.basename(filePath),
+			path: filePath,
+			sha: oid,
+			size: Buffer.byteLength(content, 'utf8'),
+			content,
+		};
+	}
+
+	async #writeCommittedFile(
+		filePath: string,
+		content: string,
+		commitMessage: string,
+	): Promise<GitFile> {
+		const fullPath = this.#getFilePath(filePath);
+		await fs.mkdir(path.dirname(fullPath), { recursive: true });
+		await fs.writeFile(fullPath, content, 'utf8');
+		await git.add({ fs, dir: this.#cfg.directory, filepath: filePath });
+		await git.commit({
+			fs,
+			dir: this.#cfg.directory,
+			message: commitMessage,
+			author: {
+				name: this.#cfg.authorName,
+				email: this.#cfg.authorEmail,
+			},
+		});
+		return this.#readWorkingTreeFile(filePath);
+	}
+
+	async #walkTree(rootDir: string, currentDir = ''): Promise<GitTreeEntry[]> {
+		const relativeDir = currentDir === '' ? rootDir : path.posix.join(rootDir, currentDir);
+		const absoluteDir = this.#getFilePath(relativeDir);
+		const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
+		const tree: GitTreeEntry[] = [];
+
+		for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
+			if (entry.name === '.git') {
+				continue;
+			}
+
+			const entryPath = relativeDir === '' ? entry.name : path.posix.join(relativeDir, entry.name);
+			const fullPath = this.#getFilePath(entryPath);
+
+			if (entry.isDirectory()) {
+				tree.push({
+					path: entryPath,
+					type: 'tree',
+					sha: '',
+				});
+				tree.push(
+					...(await this.#walkTree(
+						rootDir,
+						currentDir === '' ? entry.name : path.posix.join(currentDir, entry.name),
+					)),
+				);
+				continue;
+			}
+
+			if (!entry.isFile()) {
+				continue;
+			}
+
+			const content = await fs.readFile(fullPath);
+			const { oid } = await git.hashBlob({ object: content });
+			tree.push({
+				path: entryPath,
+				type: 'blob',
+				sha: oid,
+				size: content.length,
+			});
+		}
+
+		return tree;
+	}
+
+	#unsupportedPRs(): never {
+		throw new Error('Pull request operations are not supported for the local git client');
+	}
+
+	async #initRepo(): Promise<void> {
+		try {
+			await fs.access(`${this.#cfg.directory}/.git`);
+		} catch {
+			// If the .git directory does not exist, initialize a new repository
+			await git.init({ fs, dir: this.#cfg.directory, defaultBranch: 'main' });
+		}
+		const watcher = fs.watch(this.#cfg.directory, {
+			signal: this.#fileWatcherAbortController.signal,
+			recursive: true,
+		});
+		(async () => {
+			for await (const { eventType, filename } of watcher) {
+				if (eventType === 'change' && filename) {
+					clearTimeout(this.#fileEventTimers.get(filename));
+					this.#fileEventTimers.set(
+						filename,
+						setTimeout(() => {
+							this.#fileEventTimers.delete(filename);
+							try {
+								for (const handler of this.#fileChangeHandlers) {
+									handler(filename, 'main');
+								}
+							} catch {
+								// pass
+							}
+						}, 100),
+					);
+				}
+			}
+		})();
+	}
+
+	async readFile(path: string, branch: string): Promise<GitFile> {
+		void branch;
+		await this.#initRepo();
+		return this.#serialize(() => this.#readWorkingTreeFile(path));
+	}
+
+	async createFile(
+		path: string,
+		content: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile> {
+		void branch;
+		await this.#initRepo();
+		return this.#serialize(async () => {
+			try {
+				await fs.access(this.#getFilePath(path));
+				throw new Error(`File already exists: ${path}`);
+			} catch (err) {
+				if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
+					throw err;
+				}
+			}
+
+			return this.#writeCommittedFile(path, content, commitMessage);
+		});
+	}
+
+	async updateFile(
+		file: GitFile,
+		newContent: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile> {
+		void branch;
+		await this.#initRepo();
+		return this.#serialize(async () => {
+			const current = await this.#readWorkingTreeFile(file.path);
+			if (file.sha !== current.sha) {
+				throw new Error(`File has changed since it was read: ${file.path}`);
+			}
+
+			return this.#writeCommittedFile(file.path, newContent, commitMessage);
+		});
+	}
+
+	async deleteFile(file: GitFile, branch: string, commitMessage: string): Promise<void> {
+		void branch;
+		await this.#initRepo();
+		await this.#serialize(async () => {
+			const current = await this.#readWorkingTreeFile(file.path);
+			if (file.sha !== current.sha) {
+				throw new Error(`File has changed since it was read: ${file.path}`);
+			}
+
+			const fullPath = this.#getFilePath(file.path);
+			await fs.unlink(fullPath);
+			await git.remove({ fs, dir: this.#cfg.directory, filepath: file.path });
+			await git.commit({
+				fs,
+				dir: this.#cfg.directory,
+				message: commitMessage,
+				author: { name: this.#cfg.authorName, email: this.#cfg.authorEmail },
+			});
+		});
+	}
+
+	async readRepoTree(dir: string, branch: string): Promise<GitTreeEntry[]> {
+		void branch;
+		await this.#initRepo();
+		return this.#serialize(async () => {
+			const rootDir = dir === '.' ? '' : dir.replace(/^\/+|\/+$/g, '');
+			const absoluteRoot = this.#getFilePath(rootDir);
+			try {
+				const stat = await fs.stat(absoluteRoot);
+				if (!stat.isDirectory()) {
+					throw new Error(`Expected directory at ${dir}`);
+				}
+			} catch (err) {
+				if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+					return [];
+				}
+
+				throw err;
+			}
+
+			return this.#walkTree(rootDir);
+		});
+	}
+
+	async listPRs(state?: 'open' | 'closed' | 'all'): Promise<GitPullRequest[]> {
+		void state;
+		return [];
+	}
+
+	async readPR(number: number): Promise<GitPullRequest> {
+		void number;
+		this.#unsupportedPRs();
+	}
+
+	async createPR(
+		title: string,
+		branch: string,
+		base?: string,
+		body?: string,
+	): Promise<GitPullRequest> {
+		const now = new Date();
+		return {
+			id: 0,
+			number: 0,
+			title,
+			body: body ?? '',
+			state: 'closed',
+			htmlUrl: '',
+			head: { ref: '', sha: '' },
+			base: { ref: '', sha: '' },
+			merged: true,
+			mergedAt: now,
+			createdAt: now,
+			updatedAt: now,
+		};
+	}
+
+	async listCommits(file: GitFile, branch: string): Promise<GitCommit[]> {
+		void branch;
+		await this.#initRepo();
+		return this.#serialize(async () => {
+			const fullPath = this.#getFilePath(file.path);
+			try {
+				const stat = await fs.stat(fullPath);
+				if (!stat.isFile()) {
+					throw new Error(`Expected file at ${file.path}`);
+				}
+			} catch (err) {
+				if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+					return [];
+				}
+				throw err;
+			}
+			const log = await git.log({ fs, dir: this.#cfg.directory, filepath: file.path });
+			return log.map((entry) => ({
+				sha: entry.oid,
+				author: entry.commit.author.name,
+				message: entry.commit.message,
+				createdAt: new Date(entry.commit.author.timestamp * 1000),
+			}));
+		});
+	}
+
+	onFileChange(handler: (filePath: string, branch: string) => void): void {
+		this.#fileChangeHandlers.push(handler);
+	}
+}
+
+export { LocalClient };

+ 41 - 0
src/lib/server/git/types.ts

@@ -0,0 +1,41 @@
+import type { GitCommit, GitPullRequest } from '$lib/types/workspace';
+
+type GitFile = {
+	name: string;
+	path: string;
+	sha: string;
+	size: number;
+	content: string;
+};
+
+type GitTreeEntry = {
+	path: string;
+	type: 'blob' | 'tree' | 'commit';
+	sha: string;
+	size?: number;
+};
+
+interface GitClient {
+	readFile(path: string, branch: string): Promise<GitFile>;
+	createFile(
+		path: string,
+		content: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile>;
+	updateFile(
+		file: GitFile,
+		newContent: string,
+		branch: string,
+		commitMessage: string,
+	): Promise<GitFile>;
+	deleteFile(file: GitFile, branch: string, commitMessage: string): Promise<void>;
+	readRepoTree(dir: string, branch: string): Promise<GitTreeEntry[]>;
+	listPRs(state?: 'open' | 'closed' | 'all'): Promise<GitPullRequest[]>;
+	readPR(number: number): Promise<GitPullRequest>;
+	createPR(title: string, branch: string, base?: string, body?: string): Promise<GitPullRequest>;
+	listCommits(file: GitFile, branch: string): Promise<GitCommit[]>;
+	onFileChange(handler: (filePath: string, branch: string) => void): void;
+}
+
+export type { GitClient, GitFile, GitTreeEntry };

+ 2 - 1
src/lib/server/logger.ts

@@ -59,5 +59,6 @@ const child = (domain: string) => root.child({ domain });
 
 
 const serverLogger = child('server');
 const serverLogger = child('server');
 const authLogger = child('auth');
 const authLogger = child('auth');
+const gitLogger = child('git');
 
 
-export { authLogger, root as logger, serverLogger };
+export { authLogger, gitLogger, root as logger, serverLogger };

+ 15 - 0
src/lib/server/server.ts

@@ -1,8 +1,10 @@
 import { serverLogger as log } from '$lib/server/logger';
 import { serverLogger as log } from '$lib/server/logger';
+import type { Workspace } from '$lib/types/workspace';
 
 
 import { env } from '$env/dynamic/private';
 import { env } from '$env/dynamic/private';
 
 
 import { Auth } from './auth';
 import { Auth } from './auth';
+import { Catalog } from './catalog';
 import { WorkspaceManager } from './workspace';
 import { WorkspaceManager } from './workspace';
 
 
 class Server {
 class Server {
@@ -11,6 +13,7 @@ class Server {
 
 
 	#ws!: WorkspaceManager;
 	#ws!: WorkspaceManager;
 	#auth: Auth | undefined;
 	#auth: Auth | undefined;
+	#catalog!: Catalog;
 
 
 	async init(): Promise<void> {
 	async init(): Promise<void> {
 		if (this.#initialized) return;
 		if (this.#initialized) return;
@@ -18,6 +21,8 @@ class Server {
 		log.info('Server initializing');
 		log.info('Server initializing');
 
 
 		await Promise.all([this.#createWorkspaceManager(), this.#createAuth()]);
 		await Promise.all([this.#createWorkspaceManager(), this.#createAuth()]);
+		const workspaces = this.#ws.list();
+		await Promise.all([this.#createCatalog(workspaces)]);
 
 
 		log.info('Server ready');
 		log.info('Server ready');
 	}
 	}
@@ -44,6 +49,12 @@ class Server {
 		this.#auth = auth;
 		this.#auth = auth;
 	}
 	}
 
 
+	async #createCatalog(workspaces: Workspace[]): Promise<void> {
+		const catalog = new Catalog(workspaces);
+		await catalog.init();
+		this.#catalog = catalog;
+	}
+
 	get ws(): WorkspaceManager {
 	get ws(): WorkspaceManager {
 		return this.#ws;
 		return this.#ws;
 	}
 	}
@@ -51,6 +62,10 @@ class Server {
 	get auth(): Auth | undefined {
 	get auth(): Auth | undefined {
 		return this.#auth;
 		return this.#auth;
 	}
 	}
+
+	get catalog(): Catalog {
+		return this.#catalog;
+	}
 }
 }
 
 
 // Singleton shared across hooks and route handlers.
 // Singleton shared across hooks and route handlers.

+ 114 - 0
src/lib/types/custom-resources/agent.ts

@@ -0,0 +1,114 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+
+import { type Agent, type IDRef } from '$lib/types/entities';
+import * as V1alpha1Agent from '$lib/types/locostack.com/v1alpha1/Agent';
+
+import { fromTemplate, parseCsv, resourceLabels, toTemplate } from './types';
+
+type AgentCR = Omit<V1alpha1Agent.components['schemas']['Agent'], 'metadata'> & {
+	metadata: V1ObjectMeta;
+};
+
+const agentLabels = (agent: Agent): { [key: string]: string } =>
+	resourceLabels(agent.stack, 'Agent', agent.id);
+
+const agentAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value ? { [`agents.locostack.com/${key}`]: value } : {};
+
+const readAgentAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`locostack.com/${key}`];
+
+const agentAnnotations = (agent: Agent): { [key: string]: string } => ({
+	...agentAnnotation('name', agent.name),
+	...agentAnnotation('icon', agent.icon),
+	...agentAnnotation('description', agent.description),
+	...agentAnnotation('tags', agent.tags?.join(',')),
+});
+
+const toModelRef = (ref: IDRef) => ({
+	kind: (ref.type === 'managed' ? 'ManagedModel' : 'ExternalModel') as
+		| 'ManagedModel'
+		| 'ExternalModel',
+	name: ref.id,
+});
+
+const fromModelRef = (ref: { kind?: string; name?: string }): IDRef => ({
+	type: ref.kind === 'ManagedModel' ? 'managed' : 'external',
+	id: ref.name || '',
+	name: ref.name,
+});
+
+const toToolRef = (ref: IDRef) => ({
+	kind: (ref.type === 'managed' ? 'ManagedTool' : 'ExternalTool') as 'ManagedTool' | 'ExternalTool',
+	name: ref.id,
+});
+
+const fromToolRef = (ref: { kind?: string; name?: string }): IDRef => ({
+	type: ref.kind === 'ManagedTool' ? 'managed' : 'external',
+	id: ref.name || '',
+	name: ref.name,
+});
+
+const toKBRef = (ref: IDRef) => ({
+	kind: (ref.type === 'managed' ? 'ManagedKnowledgeBase' : 'ExternalKnowledgeBase') as
+		| 'ManagedKnowledgeBase'
+		| 'ExternalKnowledgeBase',
+	name: ref.id,
+});
+
+const fromKBRef = (ref: { kind?: string; name?: string }): IDRef => ({
+	type: ref.kind === 'ManagedKnowledgeBase' ? 'managed' : 'external',
+	id: ref.name || '',
+	name: ref.name,
+});
+
+const toAgentCR = (agent: Agent): AgentCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'Agent',
+		metadata: {
+			name: agent.id,
+			annotations: agentAnnotations(agent),
+			labels: agentLabels(agent),
+		},
+		spec: {
+			...(agent.stack ? { stackRef: { name: agent.stack } } : {}),
+			model: toModelRef(agent.model),
+			...(agent.tools.length > 0
+				? {
+						tools: agent.tools.map((t) => toToolRef(t)),
+					}
+				: {}),
+			...(agent.kbs.length > 0
+				? {
+						knowledgeBases: agent.kbs.map((kb) => toKBRef(kb)),
+					}
+				: {}),
+			...(agent.systemPrompt ? { systemPrompt: agent.systemPrompt } : {}),
+			template: toTemplate(agent.runtime),
+		},
+	};
+};
+
+const fromAgentCR = (cr: AgentCR): Agent => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) throw new Error('metadata.name is required');
+	return {
+		id,
+		stack: cr.spec.stackRef?.name,
+		name: readAgentAnnotation(metadata, 'name') ?? metadata.name ?? '',
+		icon: readAgentAnnotation(metadata, 'icon'),
+		description: readAgentAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readAgentAnnotation(metadata, 'tags')) ?? [],
+		systemPrompt: spec.systemPrompt,
+		model: fromModelRef(spec.model),
+		tools: spec.tools?.map((t) => fromToolRef(t)) ?? [],
+		kbs: spec.knowledgeBases?.map((kb) => fromKBRef(kb)) ?? [],
+		runtime: fromTemplate(cr.spec.template),
+	};
+};
+
+export type { AgentCR };
+export { fromAgentCR, toAgentCR };

+ 5 - 0
src/lib/types/custom-resources/index.ts

@@ -0,0 +1,5 @@
+export * from './agent';
+export * from './kb';
+export * from './model';
+export * from './tool';
+export * from './types';

+ 233 - 0
src/lib/types/custom-resources/kb.ts

@@ -0,0 +1,233 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+
+import {
+	type ChunkingStrategy,
+	type ExternalKB,
+	type IDRef,
+	type KBDocument,
+	type KBDocumentSource,
+	type KBDocumentType,
+	type KnowledgeBase,
+	type ManagedKB,
+} from '$lib/types/entities';
+import * as V1alpha1Document from '$lib/types/locostack.com/v1alpha1/Document';
+import * as V1alpha1ExternalKnowledgeBase from '$lib/types/locostack.com/v1alpha1/ExternalKnowledgeBase';
+import * as V1alpha1ManagedKnowledgeBase from '$lib/types/locostack.com/v1alpha1/ManagedKnowledgeBase';
+
+import { fromTemplate, parseCsv, resourceLabels, toTemplate } from './types';
+
+type ExternalKBCR = Omit<
+	V1alpha1ExternalKnowledgeBase.components['schemas']['ExternalKnowledgeBase'],
+	'metadata'
+> & {
+	metadata: V1ObjectMeta;
+};
+
+type ManagedKBCR = Omit<
+	V1alpha1ManagedKnowledgeBase.components['schemas']['ManagedKnowledgeBase'],
+	'metadata'
+> & {
+	metadata: V1ObjectMeta;
+};
+
+type DocumentCR = Omit<V1alpha1Document.components['schemas']['Document'], 'metadata'> & {
+	metadata: V1ObjectMeta;
+};
+
+const kbLabels = (kb: KnowledgeBase): { [key: string]: string } =>
+	resourceLabels(kb.stack, 'KnowledgeBase', kb.id);
+
+const kbAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value ? { [`kbs.locostack.com/${key}`]: value } : {};
+
+const readKbAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`kbs.locostack.com/${key}`];
+
+const kbAnnotations = (kb: KnowledgeBase): { [key: string]: string } => ({
+	...kbAnnotation('name', kb.name),
+	...kbAnnotation('icon', kb.icon),
+	...kbAnnotation('description', kb.description),
+	...kbAnnotation('tags', kb.tags?.join(',')),
+});
+
+const toChunking = (chunking: ChunkingStrategy) => ({
+	chunkSize: chunking.size,
+	chunkOverlap: chunking.overlap,
+	strategy: chunking.strategy as 'fixed' | 'recursive' | 'markdown',
+	...(chunking.separators ? { separators: chunking.separators } : {}),
+});
+
+const fromChunking = (c: {
+	chunkSize: number;
+	chunkOverlap: number;
+	strategy: string;
+	separators?: string[];
+}): ChunkingStrategy => ({
+	strategy: c.strategy,
+	size: c.chunkSize,
+	overlap: c.chunkOverlap,
+	separators: c.separators,
+});
+
+const toModelRef = (ref: IDRef) => ({
+	kind: (ref.type === 'managed' ? 'ManagedModel' : 'ExternalModel') as
+		| 'ManagedModel'
+		| 'ExternalModel',
+	name: ref.id,
+});
+
+const fromModelRef = (ref: { kind?: string; name?: string }): IDRef => ({
+	type: ref.kind === 'ManagedModel' ? 'managed' : 'external',
+	id: ref.name || '',
+	name: ref.name,
+});
+
+const toExternalKBCR = (kb: ExternalKB): ExternalKBCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ExternalKnowledgeBase',
+		metadata: {
+			name: kb.id,
+			annotations: kbAnnotations(kb),
+			labels: kbLabels(kb),
+		},
+		spec: {},
+	};
+};
+
+const fromExternalKBCR = (cr: ExternalKBCR): ExternalKB => {
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) throw new Error('metadata.name is required');
+	return {
+		id,
+		stack: undefined,
+		name: readKbAnnotation(metadata, 'name') ?? metadata.name ?? '',
+		icon: readKbAnnotation(metadata, 'icon'),
+		description: readKbAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readKbAnnotation(metadata, 'tags')) ?? [],
+		documents: [],
+		mode: 'external',
+	};
+};
+
+const toManagedKBCR = (kb: ManagedKB): ManagedKBCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ManagedKnowledgeBase',
+		metadata: {
+			name: kb.id,
+			annotations: kbAnnotations(kb),
+			labels: kbLabels(kb),
+		},
+		spec: {
+			...(kb.stack ? { stackRef: { name: kb.stack } } : {}),
+			...(kb.embedding ? { embeddingModelRef: toModelRef(kb.embedding) } : {}),
+			...(kb.reranker ? { rerankerModelRef: toModelRef(kb.reranker) } : {}),
+			template: toTemplate(kb.runtime),
+			...(kb.ingestion ? { ingestionJob: toTemplate(kb.ingestion) } : {}),
+			...(kb.egestion ? { egestionJob: toTemplate(kb.egestion) } : {}),
+		},
+	};
+};
+
+const fromManagedKBCR = (cr: ManagedKBCR): ManagedKB => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) throw new Error('metadata.name is required');
+	return {
+		id,
+		stack: spec.stackRef?.name,
+		name: readKbAnnotation(metadata, 'name') ?? metadata.name ?? '',
+		icon: readKbAnnotation(metadata, 'icon'),
+		description: readKbAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readKbAnnotation(metadata, 'tags')) ?? [],
+		documents: [],
+		mode: 'managed',
+		embedding: spec.embeddingModelRef ? fromModelRef(spec.embeddingModelRef) : undefined,
+		reranker: spec.rerankerModelRef ? fromModelRef(spec.rerankerModelRef) : undefined,
+		runtime: fromTemplate(spec.template),
+		ingestion: fromTemplate(spec.ingestionJob),
+		egestion: fromTemplate(spec.egestionJob),
+	};
+};
+
+const toDocumentCR = (doc: KBDocument): DocumentCR => {
+	// TODO: support more source types
+	if (doc.source.type !== 'upload') {
+		throw new Error('Only "upload" source is supported');
+	}
+	const source = {
+		pvc: {
+			claimName: 'shared-upload',
+			filePath: doc.source.fileName,
+		},
+	};
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'Document',
+		metadata: {
+			name: doc.id,
+			annotations: {
+				...kbAnnotation('name', doc.name),
+				...kbAnnotation('type', doc.type),
+				...kbAnnotation('size', doc.sizeBytes.toString()),
+			},
+		},
+		spec: {
+			knowledgeBaseRef: {
+				kind: 'ManagedKnowledgeBase',
+				name: doc.kb.id,
+			},
+			source,
+			chunkingStrategy: toChunking(doc.chunking),
+		},
+	};
+};
+
+const fromDocumentCR = (cr: DocumentCR): KBDocument => {
+	let source: KBDocumentSource = { type: 'unknown' };
+	const crSource = cr.spec.source;
+	if (crSource.pvc?.claimName === 'shared-upload') {
+		source = {
+			type: 'upload',
+			fileName: crSource.pvc?.filePath ?? '',
+		};
+	}
+	return {
+		id: cr.metadata.name ?? '',
+		description: readKbAnnotation(cr.metadata, 'description') ?? '',
+		tags: parseCsv(readKbAnnotation(cr.metadata, 'tags')) ?? [],
+		kb: {
+			type: 'managed',
+			id: cr.spec.knowledgeBaseRef.name!,
+		},
+		name: readKbAnnotation(cr.metadata, 'name') ?? '',
+		type: (readKbAnnotation(cr.metadata, 'type') ?? 'text') as KBDocumentType,
+		sizeBytes: parseInt(readKbAnnotation(cr.metadata, 'size') ?? '0', 10),
+		source,
+		chunking: fromChunking(cr.spec.chunkingStrategy),
+	};
+};
+
+const fromKBCR = (cr: ExternalKBCR | ManagedKBCR): KnowledgeBase => {
+	if (cr.kind === 'ExternalKnowledgeBase') {
+		return fromExternalKBCR(cr as ExternalKBCR);
+	} else if (cr.kind === 'ManagedKnowledgeBase') {
+		return fromManagedKBCR(cr as ManagedKBCR);
+	} else {
+		throw new Error(`Unknown KB CR kind: ${cr.kind}`);
+	}
+};
+
+export type { DocumentCR, ExternalKBCR, ManagedKBCR };
+export {
+	fromDocumentCR,
+	fromExternalKBCR,
+	fromKBCR,
+	fromManagedKBCR,
+	toDocumentCR,
+	toExternalKBCR,
+	toManagedKBCR,
+};

+ 246 - 0
src/lib/types/custom-resources/model.ts

@@ -0,0 +1,246 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+
+import {
+	type ExternalModel,
+	type ManagedModel,
+	type Model,
+	MODEL_MODALITIES,
+	type ModelBase,
+	type ModelModality,
+} from '$lib/types/entities';
+import * as V1alpha1ExternalModel from '$lib/types/locostack.com/v1alpha1/ExternalModel';
+import * as V1alpha1ManagedModel from '$lib/types/locostack.com/v1alpha1/ManagedModel';
+
+import {
+	fromAuth,
+	fromTemplate,
+	parseCsv,
+	parseIntString,
+	resourceLabels,
+	toAuth,
+	toTemplate,
+} from './types';
+
+type ExternalModelCR = Omit<
+	V1alpha1ExternalModel.components['schemas']['ExternalModel'],
+	'metadata'
+> & {
+	metadata: V1ObjectMeta;
+};
+
+type ManagedModelCR = Omit<
+	V1alpha1ManagedModel.components['schemas']['ManagedModel'],
+	'metadata'
+> & {
+	metadata: V1ObjectMeta;
+};
+
+const modelModalitySet = new Set(MODEL_MODALITIES.map((m) => m.value));
+
+const parseModelModalities = (value: string | undefined): ModelModality[] | undefined => {
+	const values = parseCsv(value);
+	if (!values) {
+		return undefined;
+	}
+	const modalities = values.filter((v): v is ModelModality => modelModalitySet.has(v));
+	return modalities.length > 0 ? modalities : undefined;
+};
+
+const modelLabels = (model: ModelBase): { [key: string]: string } =>
+	resourceLabels(model.stack, 'Model', model.id);
+
+const modelAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value
+		? {
+				[`models.locostack.com/${key}`]: value,
+			}
+		: {};
+
+const modelAnnotations = (model: ModelBase): { [key: string]: string } => ({
+	...modelAnnotation('icon', model.icon),
+	...modelAnnotation('description', model.description),
+	...modelAnnotation('tags', model.tags?.join(',')),
+	...modelAnnotation('family', model.info.family),
+	...modelAnnotation('baseModelId', model.info.baseModelId),
+	...modelAnnotation('releaseDate', model.info.releaseDate),
+	...modelAnnotation('parameterCount', model.info.parameterCount?.toString()),
+	...modelAnnotation('contextWindow', model.info.contextWindow?.toString()),
+	...modelAnnotation('inputs', model.info.inputs?.join(',')),
+	...modelAnnotation('outputs', model.info.outputs?.join(',')),
+	...modelAnnotation('embeddingDimensions', model.info.embeddingDimensions?.toString()),
+	...modelAnnotation('maxInputTokens', model.info.maxInputTokens?.toString()),
+	...modelAnnotation('languages', model.info.languages?.join(',')),
+});
+
+const readModelAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`models.locostack.com/${key}`];
+
+const parseModelBase = (cr: ExternalModelCR | ManagedModelCR): ModelBase => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+	return {
+		id,
+		stack: spec.stackRef?.name,
+		name: spec.modelName,
+		icon: readModelAnnotation(metadata, 'icon'),
+		description: readModelAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readModelAnnotation(metadata, 'tags')) ?? [],
+		category: spec.category,
+		info: {
+			family: readModelAnnotation(metadata, 'family'),
+			baseModelId: readModelAnnotation(metadata, 'baseModelId'),
+			releaseDate: readModelAnnotation(metadata, 'releaseDate'),
+			parameterCount: readModelAnnotation(metadata, 'parameterCount'),
+			contextWindow: parseIntString(readModelAnnotation(metadata, 'contextWindow')),
+			inputs: parseModelModalities(readModelAnnotation(metadata, 'inputs')),
+			outputs: parseModelModalities(readModelAnnotation(metadata, 'outputs')),
+			embeddingDimensions: parseIntString(readModelAnnotation(metadata, 'embeddingDimensions')),
+			maxInputTokens: parseIntString(readModelAnnotation(metadata, 'maxInputTokens')),
+			languages: parseCsv(readModelAnnotation(metadata, 'languages')),
+		},
+	};
+};
+
+const toExternalModelCR = (model: ExternalModel): ExternalModelCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ExternalModel',
+		metadata: {
+			name: model.id,
+			annotations: modelAnnotations(model),
+			labels: modelLabels(model),
+		},
+		spec: {
+			...(model.stack ? { stackRef: { name: model.stack } } : {}),
+			category: model.category,
+			modelName: model.name,
+			provider: model.provider,
+			providerModel: model.providerModel,
+			apiBase: model.apiBase,
+			apiVersion: model.apiVersion,
+			auth: toAuth(model.auth),
+			extraParams: model.extraParams,
+			...(model.defaultInferenceParams
+				? {
+						defaultInferenceParams: {
+							...model.defaultInferenceParams,
+						},
+					}
+				: {}),
+		},
+	};
+};
+
+const fromExternalModelCR = (cr: ExternalModelCR): ExternalModel => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+
+	return {
+		...parseModelBase(cr),
+		mode: 'external',
+		provider: spec.provider,
+		providerModel: spec.providerModel,
+		apiBase: spec.apiBase,
+		apiVersion: spec.apiVersion,
+		auth: fromAuth(spec.auth),
+		extraParams: spec.extraParams,
+		defaultInferenceParams: spec.defaultInferenceParams,
+	};
+};
+
+const toManagedModelCR = (model: ManagedModel): ManagedModelCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ManagedModel',
+		metadata: {
+			name: model.id,
+			annotations: modelAnnotations(model),
+			labels: modelLabels(model),
+		},
+		spec: {
+			...(model.stack ? { stackRef: { name: model.stack } } : {}),
+			category: model.category,
+			modelName: model.name,
+			weights: {
+				...(model.huggingFace
+					? {
+							huggingFace: {
+								endpoint: model.huggingFace?.endpoint,
+								repo: model.huggingFace?.repo || '',
+								revision: model.huggingFace?.revision || 'main',
+								fileName: model.huggingFace?.fileName || '',
+							},
+						}
+					: {}),
+			},
+			runtimeInferenceParameters: {
+				...(model.runtimeInferenceParameters?.chatTemplate
+					? { chatTemplate: model.runtimeInferenceParameters.chatTemplate }
+					: {}),
+				...(model.runtimeInferenceParameters?.contextWindow !== undefined
+					? { contextWindow: model.runtimeInferenceParameters.contextWindow }
+					: {}),
+				...(model.runtimeInferenceParameters?.temperature !== undefined
+					? { temperature: model.runtimeInferenceParameters.temperature }
+					: {}),
+				...(model.runtimeInferenceParameters?.topP !== undefined
+					? { topP: model.runtimeInferenceParameters.topP }
+					: {}),
+				...(model.runtimeInferenceParameters?.topK !== undefined
+					? { topK: model.runtimeInferenceParameters.topK }
+					: {}),
+			},
+			template: toTemplate(model.runtime),
+		},
+	};
+};
+
+const fromManagedModelCR = (cr: ManagedModelCR): ManagedModel => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+	return {
+		...parseModelBase(cr),
+		mode: 'managed',
+		huggingFace: spec.weights.huggingFace
+			? {
+					endpoint: spec.weights.huggingFace.endpoint,
+					repo: spec.weights.huggingFace.repo,
+					revision: spec.weights.huggingFace.revision,
+					fileName: spec.weights.huggingFace.fileName,
+				}
+			: undefined,
+		runtime: fromTemplate(spec.template),
+		runtimeInferenceParameters: spec.runtimeInferenceParameters,
+	};
+};
+
+const fromModelCR = (cr: ExternalModelCR | ManagedModelCR): Model => {
+	if (cr.kind === 'ExternalModel') {
+		return fromExternalModelCR(cr as ExternalModelCR);
+	} else if (cr.kind === 'ManagedModel') {
+		return fromManagedModelCR(cr as ManagedModelCR);
+	} else {
+		throw new Error(`Unknown model CR kind: ${cr.kind}`);
+	}
+};
+
+export type { ExternalModelCR, ManagedModelCR };
+export {
+	fromExternalModelCR,
+	fromManagedModelCR,
+	fromModelCR,
+	toExternalModelCR,
+	toManagedModelCR,
+};

+ 128 - 0
src/lib/types/custom-resources/tool.ts

@@ -0,0 +1,128 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+
+import type { ExternalTool, ManagedTool, Tool, ToolBase } from '$lib/types/entities';
+import * as V1alpha1ExternalTool from '$lib/types/locostack.com/v1alpha1/ExternalTool';
+import * as V1alpha1ManagedTool from '$lib/types/locostack.com/v1alpha1/ManagedTool';
+
+import { fromAuth, fromTemplate, parseCsv, resourceLabels, toAuth, toTemplate } from './types';
+
+type ExternalToolCR = Omit<
+	V1alpha1ExternalTool.components['schemas']['ExternalTool'],
+	'metadata'
+> & {
+	metadata: V1ObjectMeta;
+};
+
+type ManagedToolCR = Omit<V1alpha1ManagedTool.components['schemas']['ManagedTool'], 'metadata'> & {
+	metadata: V1ObjectMeta;
+};
+
+const toolLabels = (tool: ToolBase): { [key: string]: string } =>
+	resourceLabels(tool.stack, 'Tool', tool.id);
+
+const toolAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value
+		? {
+				[`tools.locostack.com/${key}`]: value,
+			}
+		: {};
+
+const toolAnnotations = (tool: ToolBase): { [key: string]: string } => ({
+	...toolAnnotation('icon', tool.icon),
+	...toolAnnotation('description', tool.description),
+	...toolAnnotation('tags', tool.tags?.join(',')),
+});
+
+const readToolAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`tools.locostack.com/${key}`];
+
+const toExternalToolCR = (tool: ExternalTool): ExternalToolCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ExternalTool',
+		metadata: {
+			name: tool.id,
+			annotations: toolAnnotations(tool),
+			labels: toolLabels(tool),
+		},
+		spec: {
+			...(tool.stack ? { stackRef: { name: tool.stack } } : {}),
+			toolName: tool.name,
+			endpoint: tool.endpoint,
+			transport: tool.transport as 'sse' | 'streamable-http',
+			auth: toAuth(tool.auth),
+		},
+	};
+};
+
+const fromExternalToolCR = (cr: ExternalToolCR): ExternalTool => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+	return {
+		id,
+		stack: spec.stackRef?.name,
+		name: spec.toolName,
+		icon: readToolAnnotation(metadata, 'icon'),
+		description: readToolAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readToolAnnotation(metadata, 'tags')) ?? [],
+		transport: spec.transport,
+		mode: 'external',
+		endpoint: spec.endpoint,
+		auth: fromAuth(spec.auth),
+	};
+};
+
+const toManagedToolCR = (tool: ManagedTool): ManagedToolCR => {
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'ManagedTool',
+		metadata: {
+			name: tool.id,
+			annotations: toolAnnotations(tool),
+			labels: toolLabels(tool),
+		},
+		spec: {
+			...(tool.stack ? { stackRef: { name: tool.stack } } : {}),
+			toolName: tool.name,
+			transport: tool.transport,
+			template: toTemplate(tool.runtime),
+		},
+	};
+};
+
+const fromManagedToolCR = (cr: ManagedToolCR): ManagedTool => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+	return {
+		id,
+		stack: spec.stackRef?.name,
+		name: spec.toolName,
+		icon: readToolAnnotation(metadata, 'icon'),
+		description: readToolAnnotation(metadata, 'description') ?? '',
+		tags: parseCsv(readToolAnnotation(metadata, 'tags')) ?? [],
+		transport: spec.transport,
+		mode: 'managed',
+		runtime: fromTemplate(spec.template),
+	};
+};
+
+const fromToolCR = (cr: ExternalToolCR | ManagedToolCR): Tool => {
+	if (cr.kind === 'ExternalTool') {
+		return fromExternalToolCR(cr as ExternalToolCR);
+	} else if (cr.kind === 'ManagedTool') {
+		return fromManagedToolCR(cr as ManagedToolCR);
+	} else {
+		throw new Error(`Unknown tool CR kind: ${cr.kind}`);
+	}
+};
+
+export type { ExternalToolCR, ManagedToolCR };
+export { fromExternalToolCR, fromManagedToolCR, fromToolCR, toExternalToolCR, toManagedToolCR };

+ 154 - 0
src/lib/types/custom-resources/types.ts

@@ -0,0 +1,154 @@
+import type { AuthConfig, RuntimeTemplate } from '$lib/types/entities';
+
+const slugify = (s: string) =>
+	s
+		.toLowerCase()
+		.replace(/\s+/g, '-')
+		.replace(/[^a-z0-9-]/g, '');
+
+const resourceLabels = (
+	stack: string | undefined,
+	component: string,
+	name: string,
+	namespace?: string,
+): { [key: string]: string } => {
+	return {
+		'app.kubernetes.io/managed-by': 'loco-operator',
+		...(stack ? { 'locostack.com/stack': stack } : {}),
+		'locostack.com/component': component,
+		'locostack.com/name': name,
+		...(namespace ? { 'locostack.com/namespace': namespace } : {}),
+	};
+};
+
+const parseNumberString = (value: string | undefined): number | undefined => {
+	if (value === undefined || value === '') {
+		return undefined;
+	}
+	const parsed = Number(value);
+	return Number.isNaN(parsed) ? undefined : parsed;
+};
+
+const parseIntString = (value: string | undefined): number | undefined => {
+	if (value === undefined || value === '') {
+		return undefined;
+	}
+	const parsed = parseInt(value, 10);
+	return Number.isNaN(parsed) ? undefined : parsed;
+};
+
+const parseCsv = (value: string | undefined): string[] | undefined => {
+	if (!value) {
+		return undefined;
+	}
+	const values = value
+		.split(',')
+		.map((v) => v.trim())
+		.filter(Boolean);
+	return values.length > 0 ? values : undefined;
+};
+
+const toTemplate = (runtime: RuntimeTemplate) => {
+	const volumeMounts = runtime.volumes?.map((v) => ({
+		name: v.name,
+		mountPath: v.mountPath,
+	}));
+	const volumes = runtime.volumes?.map((v) => ({
+		name: v.name,
+		emptyDir: {
+			sizeLimit: v.size,
+		},
+	}));
+	return {
+		name: runtime.name,
+		spec: {
+			resources: {
+				limits: {
+					...(runtime.memory ? { memory: `${runtime.memory}` } : {}),
+				},
+				requests: {
+					...(runtime.cpu ? { cpu: `${runtime.cpu}` } : {}),
+					...(runtime.memory ? { memory: `${runtime.memory}` } : {}),
+					...(runtime.gpu ? { gpu: `${runtime.gpu}` } : {}),
+				},
+			},
+			runtime: {
+				...(runtime.version ? { name: runtime.version } : {}),
+				...(runtime.image ? { image: runtime.image } : {}),
+				...(runtime.command !== undefined && runtime.command.length > 0
+					? { command: runtime.command }
+					: {}),
+				...(runtime.args !== undefined && runtime.args.length > 0 ? { args: runtime.args } : {}),
+				...(runtime.extraArgs !== undefined && runtime.extraArgs.length > 0
+					? { extraArgs: runtime.extraArgs }
+					: {}),
+				...(runtime.env !== undefined && runtime.env.length > 0 ? { env: runtime.env } : {}),
+				...(runtime.port !== undefined ? { port: runtime.port } : {}),
+				...(volumeMounts ? { volumeMounts } : {}),
+			},
+			...(volumes ? { volumes } : {}),
+		},
+	};
+};
+
+const fromTemplate = (template: any): RuntimeTemplate => {
+	return {
+		name: template.name,
+		version: template.spec.runtime.name,
+		image: template.spec.runtime.image,
+		command: template.spec.runtime.command,
+		args: template.spec.runtime.args,
+		extraArgs: template.spec.runtime.extraArgs,
+		env: template.spec.runtime.env,
+		port: template.spec.runtime.port,
+		node: template.spec.nodeSelector,
+		cpu: template.spec.resources?.requests?.cpu,
+		memory: template.spec.resources?.requests?.memory,
+		gpu: template.spec.resources?.requests?.gpu,
+	};
+};
+
+const toAuth = (auth: AuthConfig) => {
+	if (auth.type === 'bearer') {
+		return { bearerToken: { name: auth.secretName, key: auth.secretKey } };
+	}
+	if (auth.type === 'api-key') {
+		return {
+			apiKey: {
+				headerName: auth.headerName,
+				secretRef: { name: auth.secretName, key: auth.secretKey },
+			},
+		};
+	}
+	return {};
+};
+
+const fromAuth = (auth: any): AuthConfig => {
+	if (auth?.bearerToken) {
+		return {
+			type: 'bearer',
+			secretName: auth.bearerToken.name,
+			secretKey: auth.bearerToken.key,
+		};
+	} else if (auth?.apiKey) {
+		return {
+			type: 'api-key',
+			headerName: auth.apiKey.headerName,
+			secretName: auth.apiKey.secretRef.name,
+			secretKey: auth.apiKey.secretRef.key,
+		};
+	}
+	return { type: 'none' };
+};
+
+export {
+	fromAuth,
+	fromTemplate,
+	parseCsv,
+	parseIntString,
+	parseNumberString,
+	resourceLabels,
+	slugify,
+	toAuth,
+	toTemplate,
+};

+ 38 - 1
src/lib/types/workspace.ts

@@ -40,4 +40,41 @@ type WorkspaceDeployment =
 			type: 'local';
 			type: 'local';
 	  };
 	  };
 
 
-export type { Workspace, WorkspaceDeployment,WorkspacePersistence };
+type PullRequestStatus = 'open' | 'merged' | 'closed';
+
+type GitPullRequest = {
+	id: number;
+	number: number;
+	title: string;
+	body: string;
+	state: 'open' | 'closed';
+	htmlUrl: string;
+	head: { ref: string; sha: string };
+	base: { ref: string; sha: string };
+	merged: boolean;
+	mergedAt?: Date;
+	createdAt?: Date;
+	updatedAt?: Date;
+};
+
+type GitCommit = {
+	sha: string;
+	author: string;
+	message: string;
+	createdAt?: Date;
+};
+
+type Changes = {
+	gitPullRequests: GitPullRequest[];
+	gitCommits: GitCommit[];
+};
+
+export type {
+	Changes,
+	GitCommit,
+	GitPullRequest,
+	PullRequestStatus,
+	Workspace,
+	WorkspaceDeployment,
+	WorkspacePersistence,
+};

+ 21 - 0
src/lib/yaml.ts

@@ -0,0 +1,21 @@
+import YAML from 'yaml';
+
+const parse = <T>(content: string): T => {
+	return YAML.parse(content);
+};
+
+const stringify = <T>(obj: T): string => {
+	return YAML.stringify(obj, {
+		sortMapEntries: true,
+	});
+};
+
+const parseMulti = <T>(content: string): T[] => {
+	return YAML.parseAllDocuments(content).map((doc) => doc.toJS());
+};
+
+const stringifyMulti = <T>(objects: T[]): string => {
+	return objects.map((obj) => stringify(obj)).join('---\n');
+};
+
+export { parse, parseMulti, stringify, stringifyMulti };

Некоторые файлы не были показаны из-за большого количества измененных файлов