feat: deploy portable workspace registry
This commit is contained in:
@@ -91,10 +91,29 @@ export function sessionRoutes(
|
||||
const locateSession = async (principal: PrincipalContext, id: string): Promise<LocatedSession | undefined> => {
|
||||
const runner = runnerFor(principal);
|
||||
// Dependency-injected runners in legacy route tests may model only the mutation under test.
|
||||
if (typeof runner.sessionShow !== "function") return {
|
||||
manifest: {}, workspaceConfigPath: (await d.workspaceRegistry.list())[0]?.snapshotPath ?? "",
|
||||
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspaceConfigPath: "" };
|
||||
const legacySession = async (): Promise<LocatedSession | undefined> => {
|
||||
try {
|
||||
const manifest = await runner.sessionShow(id);
|
||||
return manifest && !manifest.workspace_id && !manifest.workspace_revision
|
||||
? { manifest, workspaceConfigPath: "" }
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const revisions = await d.workspaceRegistry.list();
|
||||
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||
try {
|
||||
revisions = await d.workspaceRegistry.list();
|
||||
} catch (registryError) {
|
||||
// Sessions created before revision pinning still live under the installation's legacy
|
||||
// default config. Keep that compatibility path available when a fresh installation has
|
||||
// no registry snapshot yet; a pinned session remains fail-closed below.
|
||||
const legacy = await legacySession();
|
||||
if (legacy) return legacy;
|
||||
throw registryError;
|
||||
}
|
||||
for (const revision of revisions) {
|
||||
if (revision.state !== "operational") continue;
|
||||
try {
|
||||
@@ -105,7 +124,7 @@ export function sessionRoutes(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return await legacySession();
|
||||
};
|
||||
|
||||
/** Read the durable pinned descriptor only after the owner-visible manifest is located. */
|
||||
@@ -240,15 +259,19 @@ export function sessionRoutes(
|
||||
|
||||
app.post("/sessions", async (req, reply) => {
|
||||
const b = req.body as {
|
||||
question: string; name?: string; workspaceId?: string;
|
||||
question: string; name?: string; workspace?: string; workspaceId?: string;
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
};
|
||||
const principal = getPrincipal(req);
|
||||
let s: Settings;
|
||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
const runner = runnerFor(principal);
|
||||
const requestedWorkspaceId = b.workspaceId ?? s.workspace;
|
||||
if (!requestedWorkspaceId) {
|
||||
// `workspace` was the legacy request field before browser-local registry preferences.
|
||||
// It opts a pre-registry caller into the existing installation-default config only; modern
|
||||
// `workspaceId` and saved preferences must continue to resolve an immutable snapshot.
|
||||
const legacyWorkspaceRequest = typeof b.workspace === "string" && b.workspace.length > 0;
|
||||
const requestedWorkspaceId = b.workspaceId ?? (legacyWorkspaceRequest ? undefined : s.workspace);
|
||||
if (!requestedWorkspaceId && !legacyWorkspaceRequest) {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
@@ -258,23 +281,25 @@ export function sessionRoutes(
|
||||
let workspaceId: string | undefined;
|
||||
let workspaceRevision: string | undefined;
|
||||
let allowedModels: readonly string[] | undefined;
|
||||
try {
|
||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
if (resolved.revision.state !== "operational") {
|
||||
if (requestedWorkspaceId) {
|
||||
try {
|
||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
if (resolved.revision.state !== "operational") {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseAllDocuments, stringify } from "yaml";
|
||||
import { parseWorkspaceYaml, type LegacyWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
|
||||
export interface LegacyMigrationResult {
|
||||
state: "migration_required";
|
||||
source: string;
|
||||
workspace: LegacyWorkspace;
|
||||
}
|
||||
|
||||
export interface LegacyMigrationOptions {
|
||||
/** Immutable repository identifier, normally derived from the input filename by the CLI. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
type LegacyRecord = Record<string, unknown>;
|
||||
|
||||
const workspaceId = /^[a-z][a-z0-9-]{2,62}$/;
|
||||
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function record(value: unknown): LegacyRecord | undefined {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as LegacyRecord
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function literalIdentifier(value: unknown): string | undefined {
|
||||
return typeof value === "string" && identifier.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function literalText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() === value && value.length > 0 && !value.includes("${")
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function literalPort(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65_535) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function titleFor(id: string): string {
|
||||
return id.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
function sourceDocument(source: string): LegacyRecord {
|
||||
const documents = parseAllDocuments(source, { uniqueKeys: true });
|
||||
if (documents.length !== 1 || documents[0].errors.length > 0) {
|
||||
throw new Error("legacy workspace YAML must contain exactly one valid document");
|
||||
}
|
||||
const parsed = record(documents[0].toJSON());
|
||||
if (!parsed) throw new Error("legacy workspace YAML must contain an object");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function dwhFrom(source: LegacyRecord): { section: LegacyRecord; transport: "postgres_direct" | "rest_api" } {
|
||||
const dwh = record(source.dwh);
|
||||
const database = record(source.database);
|
||||
if (dwh) {
|
||||
const type = literalText(dwh.type);
|
||||
return { section: record(dwh.connection) ?? record(dwh.database) ?? dwh, transport: type === "postgres_direct" ? "postgres_direct" : "rest_api" };
|
||||
}
|
||||
if (database) {
|
||||
return { section: database, transport: literalText(database.transport) === "direct" ? "postgres_direct" : "rest_api" };
|
||||
}
|
||||
return { section: {}, transport: "rest_api" };
|
||||
}
|
||||
|
||||
function vectorFrom(source: LegacyRecord): {
|
||||
section: LegacyRecord; transport: "pgvector_direct" | "rest_api"; writer: boolean;
|
||||
} {
|
||||
const vectors = record(source.vectors);
|
||||
if (vectors) {
|
||||
const type = literalText(vectors.type);
|
||||
const direct = record(vectors.direct);
|
||||
return {
|
||||
section: type === "pgvector_direct" ? record(vectors.connection) ?? record(vectors.reader) ?? direct ?? {} : direct ?? {},
|
||||
transport: type === "pgvector_direct" ? "pgvector_direct" : "rest_api",
|
||||
writer: record(vectors.writer) !== undefined,
|
||||
};
|
||||
}
|
||||
const vectorDb = record(source.vector_db);
|
||||
return { section: vectorDb ?? {}, transport: "pgvector_direct", writer: record(source.vector_write_rest) !== undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a legacy runtime descriptor into a versioned, readable v1 registry descriptor.
|
||||
* Runtime YAMLs mix shared metadata with `${ENV}` bindings and omit semantic-index identity;
|
||||
* the result therefore always remains `migration_required` until an operator explicitly upgrades
|
||||
* it with the correct collection/database/schema contract.
|
||||
*/
|
||||
export function migrateLegacyWorkspace(source: string, options: LegacyMigrationOptions): LegacyMigrationResult {
|
||||
if (!workspaceId.test(options.id)) throw new Error("legacy workspace ID is invalid");
|
||||
const legacy = sourceDocument(source);
|
||||
const language = legacy.language === "it" ? "it" : "en";
|
||||
const { section: dwh, transport: dwhTransport } = dwhFrom(legacy);
|
||||
const { section: vector, transport: vectorTransport, writer } = vectorFrom(legacy);
|
||||
const embedding = record(legacy.embeddings) ?? {};
|
||||
const dwhDatabase = literalIdentifier(dwh.database) ?? "legacy_dwh";
|
||||
const dwhSchema = literalIdentifier(dwh.schema) ?? "public";
|
||||
const vectorDatabase = literalIdentifier(vector.database);
|
||||
const vectorSchema = literalIdentifier(vector.schema);
|
||||
const dimensions = typeof embedding.dim === "number" && Number.isInteger(embedding.dim) && embedding.dim > 0
|
||||
? embedding.dim
|
||||
: 768;
|
||||
const vectorStore: LegacyWorkspace["semantic_index"]["vector_store"] = {
|
||||
engine: "pgvector",
|
||||
collection: `${options.id.replaceAll("-", "_")}_documents`,
|
||||
dimensions,
|
||||
distance: "cosine",
|
||||
supported_transports: [vectorTransport],
|
||||
...(literalPort(vector.port) === undefined ? {} : { port: literalPort(vector.port) }),
|
||||
...(vectorDatabase === undefined ? {} : { database: vectorDatabase }),
|
||||
...(vectorSchema === undefined ? {} : { schema: vectorSchema }),
|
||||
};
|
||||
const workspace: LegacyWorkspace = {
|
||||
workspace: {
|
||||
schema_version: 1,
|
||||
id: options.id,
|
||||
name: titleFor(options.id),
|
||||
language,
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: dwhDatabase,
|
||||
schema: dwhSchema,
|
||||
supported_transports: [dwhTransport],
|
||||
...(literalPort(dwh.port) === undefined ? {} : { port: literalPort(dwh.port) }),
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: vectorStore,
|
||||
...(writer ? { vector_writer: {} } : {}),
|
||||
embedding: {
|
||||
provider: "ollama_compatible",
|
||||
model: literalText(embedding.model) ?? "legacy-embedding",
|
||||
dimensions,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
const descriptor = parseWorkspaceYaml(stringify(workspace, { lineWidth: 0, sortMapEntries: true }));
|
||||
if (descriptor.workspace.schema_version !== 1) throw new Error("legacy workspace migration is invalid");
|
||||
const migrated = descriptor as LegacyWorkspace;
|
||||
const rendered = stringify(migrated, { lineWidth: 0, sortMapEntries: true });
|
||||
return { state: "migration_required", source: rendered, workspace: migrated };
|
||||
}
|
||||
|
||||
function destinationFor(repositoryRoot: string, id: string): string {
|
||||
if (!isAbsolute(repositoryRoot)) throw new Error("migration output root must be absolute");
|
||||
if (!workspaceId.test(id)) throw new Error("legacy workspace ID is invalid");
|
||||
return join(repositoryRoot, "workspaces", `${id}.yaml`);
|
||||
}
|
||||
|
||||
/** Safely adds a migrated descriptor without replacing a previous operator-reviewed migration. */
|
||||
export async function writeMigratedWorkspace(result: LegacyMigrationResult, repositoryRoot: string): Promise<string> {
|
||||
const destination = destinationFor(repositoryRoot, result.workspace.workspace.id);
|
||||
const directory = dirname(destination);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
await lstat(destination);
|
||||
throw new Error("migrated workspace already exists");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
const temporary = join(directory, `.${result.workspace.workspace.id}.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
await writeFile(temporary, result.source, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
await rename(temporary, destination);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
function parseCliArguments(argv: readonly string[]): { input: string; output: string } {
|
||||
if (argv.length !== 4 || argv[0] !== "--input" || argv[2] !== "--output") {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root>");
|
||||
}
|
||||
if (!isAbsolute(argv[1]) || !isAbsolute(argv[3])) {
|
||||
throw new Error("migration input and output paths must be absolute");
|
||||
}
|
||||
return { input: argv[1], output: argv[3] };
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const { input, output } = parseCliArguments(argv);
|
||||
const id = basename(input, ".yaml");
|
||||
const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id });
|
||||
const destination = await writeMigratedWorkspace(result, output);
|
||||
process.stdout.write(`${destination}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "migration failed"}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user