Merge remote-tracking branch 'educraft/main' into merge/educraft-cph

# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
This commit is contained in:
2026-08-06 00:49:02 +08:00
262 changed files with 10630 additions and 1377 deletions
+121
View File
@@ -15,6 +15,10 @@
* `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection).
*
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
* roles and skills for management-surface grouping. Folder endpoints never
* touch session state — assignment is a label-class change (ADR-0017).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err);
}
});
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
return { folders };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await agentConfig.createFolder({
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
});
return reply.status(201).send(folder);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
const folder = await agentConfig.updateFolder({
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return folder;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await agentConfig.deleteFolder({
organizationId: auth.organization.id,
folderId,
});
return { deleted: true };
} catch (err) {
return handleRouteError(reply, err);
}
});
// Folder assignment is a label-class change (ADR-0017): these endpoints
// never archive Agent sessions (ADR-0028).
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setRoleFolder({
organizationId: auth.organization.id,
roleId,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setSkillFolder({
organizationId: auth.organization.id,
name,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
}
@@ -7,8 +7,12 @@
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
import {
CapabilityConnectionService,
type CapabilityCredentialInput,
} from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import { secretKindForCapability } from "../../capability/types.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
const connections =
config.readinessProbe === undefined
? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const credential = parseCredentialBody(capabilityId, request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
credential,
});
request.log.info({
organizationId: auth.organization.id,
@@ -113,19 +116,57 @@ export async function registerCapabilityConnectionRoutes(
});
}
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
const expectedKind = secretKindForCapability(capabilityId);
const kind =
body.kind === "docmind" || body.kind === "pbank"
? body.kind
: expectedKind;
if (kind !== expectedKind) {
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`);
}
if (kind === "docmind") {
return {
kind: "docmind",
accessKeyId: requireStringField(body, "accessKeyId"),
accessKeySecret: requireStringField(body, "accessKeySecret"),
endpoint: requireStringField(body, "endpoint"),
};
}
const rightsStatus = optionalStringField(body, "rightsStatus");
const rightsHolder = optionalStringField(body, "rightsHolder");
const rightsScope = optionalStringField(body, "rightsScope");
const rightsNote = optionalStringField(body, "rightsNote");
return {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
kind: "pbank",
baseUrl: requireStringField(body, "baseUrl"),
username: requireStringField(body, "username"),
password: requireStringField(body, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
function requireStringField(body: Record<string, unknown>, name: string): string {
const value = body[name];
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${name} is required`);
}
return value;
}
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined {
const value = body[name];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+6 -1
View File
@@ -155,7 +155,12 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send({ id: result.projectId, name: body.name });
return reply.status(201).send({
projectId: result.projectId,
folderId: result.folderId,
workspaceDir: result.workspaceDir,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}