diff --git a/cli/README.md b/cli/README.md index 99bb94af6..987cd84a6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -79,6 +79,12 @@ building anything, an existing deployment manifest and no `sandbox.image` overri that override only seeds the first `qm up` and must be removed afterwards. Every ordinary `up` also syncs the layer. +AWS Lambda MicroVM sandboxes use QM's fixed runtime image. They accept skills through the +deployment layer, but do not build `sandbox/Dockerfile` or copy tool executables. `check`, +`sandbox build`, and `infra build-image` reject custom tools and Dockerfiles for that +backend. Configure `sandbox.backend: "sprites"` when the deployment needs a custom sandbox +image. + Auto uses its built-in model classifier unless `qm.config.jsonc` declares one `securityScreen` proxy with a provider label, HTTPS endpoint, and `shadow` or `enforce` rollout. The proxy token is routed separately through diff --git a/cli/package-lock.json b/cli/package-lock.json index 8b469f6a2..471eabca4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "bin": { "qm": "dist/bin/qm.js" diff --git a/cli/package.json b/cli/package.json index 4fbc357d4..588271b14 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/cli/src/backends/registry.ts b/cli/src/backends/registry.ts index c07a29369..13f593d70 100644 --- a/cli/src/backends/registry.ts +++ b/cli/src/backends/registry.ts @@ -296,7 +296,7 @@ const aws: HostingProvider = { publishSandbox: async (ctx, opts) => { if (ctx.config.sandbox?.backend !== "sprites") { throw new CliError( - `this AWS deployment runs Lambda MicroVM sandboxes (sandbox.backend is not "sprites"); use \`qm sandbox build\` to validate the layer and \`qm infra build-image\` to publish the runtime — or set "sandbox.backend": "sprites" with "sandbox.app" to host sandboxes in an operator-published layer image`, + `this AWS deployment runs Lambda MicroVM sandboxes (sandbox.backend is not "sprites"), which do not support custom sandbox images; set "sandbox.backend": "sprites" with "sandbox.app" to host sandboxes in an operator-published layer image`, ); } if (!opts.dryRun) assertAwsSandboxPinRecordable(ctx.config); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 148761850..1c387310d 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -455,6 +455,7 @@ async function dispatch(argv: string[]): Promise { if (operation === "delete-image") await deleteAwsMicrovmImage(ctx.config); else await deleteAwsTaskDefinitions(ctx.config); } else if (operation === "build-image") { + runChecks(ctx.config, ctx.configDir, ctx.sandboxDir, { report: false }); await buildAwsMicrovmImage(ctx.config, ctx.configPath); } else { runChecks(ctx.config, ctx.configDir, ctx.sandboxDir, { report: false }); diff --git a/cli/src/commands/check.ts b/cli/src/commands/check.ts index c1b23cff6..6b404e0fc 100644 --- a/cli/src/commands/check.ts +++ b/cli/src/commands/check.ts @@ -24,7 +24,7 @@ export function runChecks( opts: { report?: boolean } = {}, ): ChecksResult { const report = opts.report ?? true; - const layer = validateSandboxLayer(sandboxDir); + const layer = validateSandboxLayer(sandboxDir, config); const { plugins, errors: pluginErrors } = discoverPlugins(configDir, config); const configErrors: Array<{ clause: string; message: string }> = []; const configError = (message: string, clause = "config.v1"): void => void configErrors.push({ clause, message }); diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 4572fa8f4..577dc6cd3 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -57,10 +57,11 @@ the scaffolded \`.gitignore\`. \`{ "id": ..., "advertise": ..., "install": { "binary": ... } }\`, with the executable next to it when the binary is not already in the base image. - \`sandbox/Dockerfile\` is optional and only needed for system packages or - runtimes. + runtimes. AWS Lambda MicroVM sandboxes do not build this file or copy tool + executables; use skills only or configure the sprites backend for custom tools. -The scaffold ships a working example, the \`greet\` skill and \`example-tool\`. -Copy its shape, then replace or delete it. +The scaffold ships a working \`greet\` skill. Targets that support custom sandbox +images also get \`example-tool\`. Copy their shape, then replace or delete them. ## The workflow @@ -94,6 +95,13 @@ description: Greet a teammate by name. Use whenever asked to say hello to someon Run \`example-tool \` to greet someone, e.g. \`example-tool Ada\`. `; +const AWS_GREET_SKILL = `--- +name: greet +description: Greet a teammate by name. Use whenever asked to say hello to someone. +--- +Reply with a friendly greeting addressed to the requested person. +`; + const EXAMPLE_TOOL_DESCRIPTOR = JSON.stringify({ id: "example-tool", advertise: "example-tool", install: { binary: "example-tool" } }, null, 2) + "\n"; @@ -116,8 +124,9 @@ function writeIfAbsent(dir: string, segments: string[], content: string, mode?: ok(`wrote ${rel}`); } -function scaffoldSandbox(dir: string): void { - writeIfAbsent(dir, ["sandbox", "skills", "greet", "SKILL.md"], GREET_SKILL); +function scaffoldSandbox(dir: string, target: Target): void { + writeIfAbsent(dir, ["sandbox", "skills", "greet", "SKILL.md"], target === "aws" ? AWS_GREET_SKILL : GREET_SKILL); + if (target === "aws") return; writeIfAbsent(dir, ["sandbox", "tools", "example-tool", "tool.json"], EXAMPLE_TOOL_DESCRIPTOR); writeIfAbsent(dir, ["sandbox", "tools", "example-tool", "example-tool"], EXAMPLE_TOOL_BIN, 0o755); } @@ -295,7 +304,7 @@ export function runInit(opts: { const manifests = renderSlackManifests(config); writeIfAbsent(dir, ["slack-app-manifest.yml"], manifests.bot); if (usesSlackOidc(config)) writeIfAbsent(dir, ["slack-sso-manifest.yml"], manifests.sso); - scaffoldSandbox(dir); + scaffoldSandbox(dir, target); for (const file of provider.scaffold.files(config)) writeIfAbsent(dir, file.segments, file.content); note(""); diff --git a/cli/src/commands/sandbox.ts b/cli/src/commands/sandbox.ts index fb5cd512f..05e0fb8da 100644 --- a/cli/src/commands/sandbox.ts +++ b/cli/src/commands/sandbox.ts @@ -249,7 +249,7 @@ function assertPublishPlatform(body: string): void { function prepare(opts: SandboxBuildOpts): PreparedBuild { const sandboxDir = resolve(opts.sandboxDir); - const layer = validateSandboxLayer(sandboxDir); + const layer = validateSandboxLayer(sandboxDir, opts.config); if (layer.errors.length) { throw new CliError(`sandbox check failed:\n${layer.errors.map((error) => ` - ${error}`).join("\n")}`); } diff --git a/cli/src/sandbox-layer.ts b/cli/src/sandbox-layer.ts index 49d184cce..2e8ac3474 100644 --- a/cli/src/sandbox-layer.ts +++ b/cli/src/sandbox-layer.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import type { QmConfig } from "./config.ts"; import { JUNK_FILE, deploymentLayerBundle } from "./deployment-layer.ts"; import { errMessage } from "./log.ts"; @@ -787,7 +788,8 @@ const subdirs = (dir: string): string[] => { const isCidr = (host: string): boolean => /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/.test(host) || /^[0-9A-Fa-f:]+\/\d{1,3}$/.test(host); -export function validateSandboxLayer(sandboxDir: string): SandboxValidation { +export function validateSandboxLayer(sandboxDir: string, config?: QmConfig): SandboxValidation { + const awsMicrovm = config?.target === "aws" && config.sandbox?.backend !== "sprites"; const out: SandboxValidation = { exists: existsSync(sandboxDir), hasDockerfile: existsSync(join(sandboxDir, "Dockerfile")), @@ -835,7 +837,11 @@ export function validateSandboxLayer(sandboxDir: string): SandboxValidation { const binaryName = descriptor.install?.binary ?? descriptor.id; const exePath = join(toolsDir, name, binaryName); const hasExe = isFile(exePath); - if (!hasExe && !out.hasDockerfile) { + if (awsMicrovm) { + out.errors.push( + `tool "${descriptor.id}" cannot be installed in AWS Lambda MicroVM sandboxes because tool executables and sandbox/Dockerfile are not included; remove it or set "sandbox.backend" to "sprites" and publish a sandbox image`, + ); + } else if (!hasExe && !out.hasDockerfile) { out.errors.push( `tool "${descriptor.id}" (tools/${name}/) can't get its binary on PATH: ship an executable ` + `"${binaryName}" in the folder, or add a sandbox/Dockerfile that installs it`, @@ -899,5 +905,11 @@ export function validateSandboxLayer(sandboxDir: string): SandboxValidation { } } + if (awsMicrovm && out.hasDockerfile) { + out.errors.push( + 'sandbox/Dockerfile is not built into AWS Lambda MicroVM sandboxes; remove it or set "sandbox.backend" to "sprites" and publish a sandbox image', + ); + } + return out; } diff --git a/cli/test/check.test.ts b/cli/test/check.test.ts index 5c1d504f8..c17e5c292 100644 --- a/cli/test/check.test.ts +++ b/cli/test/check.test.ts @@ -22,6 +22,24 @@ const CONFIG: QmConfig = { sandbox: { app: "acme-sandboxes", image: PINNED_SANDBOX_IMAGE }, }; +const AWS_CONFIG: QmConfig = { + ...CONFIG, + target: "aws", + publicUrl: "https://acme.example.com", + sandbox: undefined, + env: { core: { AWS_DEPLOY_IMAGE: "acme-microvm" } }, + aws: { + accountId: "123456789012", + region: "us-west-2", + cluster: "acme-qm", + deployRoleArn: "arn:aws:iam::123456789012:role/deploy", + secretsPrefix: "acme/qm/", + imageLabel: "latest", + networking: { cloudMapNamespace: "acme.internal" }, + services: { core: { ecrRepository: "core", ecsService: "core", cpu: 256, memory: 512 } }, + }, +}; + function deployment(setup: (dir: string) => void, config: Partial = {}): { dir: string; config: QmConfig } { const dir = mkdtempSync(join(tmpdir(), "qm-check-")); setup(dir); @@ -134,6 +152,55 @@ test("a tool with no executable BUT a sandbox/Dockerfile passes (Dockerfile inst } }); +test("AWS Lambda MicroVM sandboxes reject every custom tool installation path", () => { + const d = deployment((dir) => { + writeTool(dir, "shipped-tool", { id: "shipped-tool", install: { binary: "shipped-tool" } }); + writeTool(dir, "built-tool", { id: "built-tool", install: { binary: "built-tool" } }, false); + writeFileSync(join(dir, "sandbox", "Dockerfile"), "FROM base\nRUN install-built-tool\n"); + }, AWS_CONFIG); + try { + assert.throws( + () => check(d), + (error) => { + assert.match(String(error), /sandbox\/Dockerfile is not built into AWS Lambda MicroVM sandboxes/); + assert.match(String(error), /tool "shipped-tool" cannot be installed/); + assert.match(String(error), /tool "built-tool" cannot be installed/); + assert.match(String(error), /"sandbox\.backend" to "sprites"/); + return true; + }, + ); + } finally { + rmSync(d.dir, { recursive: true, force: true }); + } +}); + +test("AWS sprites sandboxes keep custom Dockerfile and executable tool support", () => { + const d = deployment( + (dir) => { + writeTool(dir, "shipped-tool", { id: "shipped-tool", install: { binary: "shipped-tool" } }); + writeFileSync(join(dir, "sandbox", "Dockerfile"), "FROM base\n"); + }, + { + ...AWS_CONFIG, + sandbox: { backend: "sprites", app: "acme-sandboxes", image: PINNED_SANDBOX_IMAGE }, + }, + ); + try { + assert.doesNotThrow(() => check(d)); + } finally { + rmSync(d.dir, { recursive: true, force: true }); + } +}); + +test("AWS Lambda MicroVM sandboxes accept skill-only layers", () => { + const d = deployment((dir) => writeSkill(dir, "greet", "name: greet\ndescription: Greet a teammate."), AWS_CONFIG); + try { + assert.doesNotThrow(() => check(d)); + } finally { + rmSync(d.dir, { recursive: true, force: true }); + } +}); + test("duplicate tool ids are flagged", () => { const d = deployment((dir) => { writeTool(dir, "folderA", { id: "same", install: { binary: "same" } }); diff --git a/cli/test/cli-dispatch.test.ts b/cli/test/cli-dispatch.test.ts index d5b2bf131..b4b1ae8a2 100644 --- a/cli/test/cli-dispatch.test.ts +++ b/cli/test/cli-dispatch.test.ts @@ -412,7 +412,7 @@ test("docker --only is rejected explicitly instead of silently restarting the fu } }); -test("sandbox publish directs MicroVM AWS deployments (no sandbox.app) to the image-build path", async () => { +test("sandbox publish rejects custom images for MicroVM AWS deployments", async () => { const dir = mkdtempSync(join(tmpdir(), "qm-dispatch-")); const configPath = join(dir, CONFIG_FILENAME); const raw = JSON.stringify({ @@ -439,14 +439,49 @@ test("sandbox publish directs MicroVM AWS deployments (no sandbox.app) to the im try { const result = await run(["sandbox", "publish", "--dry-run"], dir); assert.equal(result.exitCode, 1, result.out); - assert.match(result.out, /Lambda MicroVM sandboxes/); - assert.match(result.out, /infra build-image/); + assert.match(result.out, /sandbox\/Dockerfile is not built into AWS Lambda MicroVM sandboxes/); + assert.match(result.out, /"sandbox\.backend" to "sprites"/); assert.equal(readFileSync(configPath, "utf8"), raw); } finally { rmSync(dir, { recursive: true, force: true }); } }); +test("infra build-image rejects unsupported AWS sandbox customization before calling AWS", async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-dispatch-")); + writeFileSync( + join(dir, CONFIG_FILENAME), + JSON.stringify({ + contract: 1, + orgId: "acme", + publicUrl: "https://acme.example.com", + target: "aws", + services: ["core"], + env: { core: { AWS_DEPLOY_IMAGE: "acme-microvm-app" } }, + aws: { + accountId: "123456789012", + region: "us-west-2", + cluster: "c", + deployRoleArn: "arn:aws:iam::123456789012:role/d", + secretsPrefix: "p/", + imageLabel: "release", + networking: { cloudMapNamespace: "n" }, + services: { core: { ecrRepository: "repo", ecsService: "s", cpu: 256, memory: 512 } }, + }, + }), + ); + mkdirSync(join(dir, "sandbox")); + writeFileSync(join(dir, "sandbox", "Dockerfile"), "FROM scratch\n"); + try { + const result = await run(["infra", "build-image"], dir); + assert.equal(result.exitCode, 1, result.out); + assert.match(result.out, /sandbox\/Dockerfile is not built into AWS Lambda MicroVM sandboxes/); + assert.doesNotMatch(result.out, /missing required env|AWS account mismatch/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("sandbox publish on an AWS deployment with sandbox.app dry-runs the operator layer image", async () => { const dir = mkdtempSync(join(tmpdir(), "qm-dispatch-")); const configPath = join(dir, CONFIG_FILENAME); diff --git a/cli/test/init.test.ts b/cli/test/init.test.ts index 1c334e40a..a6a1d2f2b 100644 --- a/cli/test/init.test.ts +++ b/cli/test/init.test.ts @@ -244,6 +244,11 @@ test("init --target aws scaffolds the full hosted topology, Terraform, and the o assert.equal(config.sandbox, undefined); assert.equal(config.aws?.cluster, "acme-qm"); assert.equal(config.aws?.imageLabel, "latest"); + assert.equal(existsSync(join(dir, "sandbox", "tools")), false); + const greetSkill = readFileSync(join(dir, "sandbox", "skills", "greet", "SKILL.md"), "utf8"); + assert.doesNotMatch(greetSkill, /example-tool/); + assert.deepEqual(validateSandboxLayer(join(dir, "sandbox")).errors, []); + assert.doesNotThrow(() => quiet(() => runChecks(config, dir, join(dir, "sandbox"), { report: false }))); assert.deepEqual(config.aws?.services.core, { ecrRepository: "acme-qm-core", ecsService: "acme-qm-core", diff --git a/cli/test/sandbox-build.test.ts b/cli/test/sandbox-build.test.ts index 75a052dfc..1684a21d7 100644 --- a/cli/test/sandbox-build.test.ts +++ b/cli/test/sandbox-build.test.ts @@ -150,3 +150,13 @@ test("a broken layer (tool with no executable and no Dockerfile) fails before bu rmSync(sb, { recursive: true, force: true }); } }); + +test("sandbox build rejects custom tool images for AWS Lambda MicroVM sandboxes", () => { + const sb = sandboxDir((s) => tool(s, "x", { id: "x", install: { binary: "x" } })); + const config: QmConfig = { ...CONFIG, target: "aws", sandbox: undefined }; + try { + assert.throws(() => dryRun({ sandboxDir: sb, config }), /cannot be installed in AWS Lambda MicroVM sandboxes/); + } finally { + rmSync(sb, { recursive: true, force: true }); + } +}); diff --git a/docs/deploy-directory.md b/docs/deploy-directory.md index a020f7e02..aed726984 100644 --- a/docs/deploy-directory.md +++ b/docs/deploy-directory.md @@ -19,6 +19,8 @@ sandbox/ The Dockerfile is optional when every declared binary is present in its tool directory. Skill assets delivered through the deployment-layer API are text in v1; binaries belong in the sandbox image. +AWS Lambda MicroVM sandboxes use QM's fixed runtime image, so they do not build the deployment's Dockerfile or copy its tool executables. The CLI rejects both installation paths when the AWS sandbox backend is omitted or set to `"aws"`. Skill-only layers remain supported; deployments that need a custom sandbox image must select `sandbox.backend: "sprites"`. + ## Configuration The root object requires `contract: 1`, `orgId`, `publicUrl`, `target`, and `services` including `core`. Docker and Fly also require `sandbox.app`. On AWS the sandbox substrate is an explicit choice: omitting the `sandbox` block runs named Lambda MicroVM images; declaring one requires `sandbox.backend` — `"sprites"` boots the operator-published layer image in `sandbox.app`, `"aws"` states the MicroVM default in the file. Unknown contract majors fail closed. `target` is `docker`, `fly`, or `aws`. diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..d06d4996a 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -2905,6 +2905,30 @@ text-overflow: ellipsis; white-space: nowrap; } + .environment-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--warn) 8%, var(--surface)); + } + .environment-notice strong, + .environment-notice p { + display: block; + margin: 0; + } + .environment-notice p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + } + .environment-notice button { + flex: none; + } .governance-overview { margin: 0 0 22px; padding: 18px 20px; @@ -3987,6 +4011,13 @@

Governance

ScopeOrganization +

Effective state

@@ -5717,6 +5748,7 @@

Confirm governance change

}); let scopeDir = null; + let environmentDir = []; let scopeDirNote = "Loading scopes…"; async function loadScopeDirectory() { const r = await api("GET", "/api/scopes"); @@ -5728,6 +5760,7 @@

Confirm governance change

} viewLoadedAt.history = Date.now(); scopeDir = r.data.scopes || []; + environmentDir = r.data.environments || []; if (SCOPED.has(view) && !urlToState().session) { const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit"); @@ -6244,6 +6277,19 @@

Confirm governance change

); } window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true }); + function renderEnvironmentNotice(data) { + const notice = $("environment-notice"); + const attachment = data?.environmentAttachment; + notice.classList.toggle("hidden", !attachment); + if (!attachment) return; + const name = attachment.environmentName || shortName(attachment.environmentId); + $("environment-notice-title").textContent = "Uses named environment " + name; + $("environment-notice-detail").textContent = + "Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here."; + $("environment-notice-open").textContent = "Open " + name; + $("environment-notice-open").onclick = () => + go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 }); + } let governanceReq = 0; async function loadScope() { const requestedScope = scope; @@ -6259,6 +6305,7 @@

Confirm governance change

); return; } + renderEnvironmentNotice(r.data); renderGovernanceOverview(r.data); syncGovernanceSectionNav(); loadedCommandPolicyPresent = r.data.commandPolicy != null; @@ -11451,6 +11498,21 @@

Confirm governance change

actions: [sortControl], }); const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0); + if (environmentDir.length) { + const environments = denseList( + environmentDir, + (environment) => ({ + name: environment.name || shortName(environment.id), + preview: plural(environment.attachedScopes?.length || 0, "attached scope"), + href: stateToUrl({ view: "history", scope: environment.id, historyKind }), + }), + (environment) => selectScope(environment.id), + "No named environments.", + ); + root.appendChild( + dataCard("Named environments", "Named computers and working memory that scopes can share.", environments), + ); + } const t = denseList( activeRows, (s) => { diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts new file mode 100644 index 000000000..3ec8c9fb8 --- /dev/null +++ b/plugins/admin/test/environments.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8"); + +test("the admin UI lists named environments and links attachment warnings", () => { + assert.match(html, /Named environments/); + assert.match(html, /id="environment-notice"/); + assert.match(html, /Uses named environment/); + assert.match(html, /scope: attachment\.environmentId/); +}); diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..17d206168 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const crons = await app.listCrons(); const deployments = await app.listDeployments(); const skills = await app.listSkills(); + const environmentRows = await app.listEnvironments(); + const environments = environmentRows.map(({ environment, attachments }) => ({ + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(), + })); + const environmentById = new Map(environments.map((environment) => [environment.id, environment])); + const attachmentByScope = new Map( + environments.flatMap((environment) => + environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const), + ), + ); const owners = [ ...crons.map((c) => c.ownerScopeId), ...deployments.map((d) => d.ownerScopeId), ...skills.map((s) => s.scopeId), + ...environments.map((environment) => environment.id), + ...environments.flatMap((environment) => environment.attachedScopes), ]; const labels = await discoverScopes(app, deps, owners); const countBy = (ids: string[]): Map => { @@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const cronN = countBy(crons.map((c) => c.ownerScopeId)); const deployN = countBy(deployments.map((d) => d.ownerScopeId)); const skillN = countBy(skills.map((s) => s.scopeId)); - const scopes = [...labels].map(([id, label]) => ({ - scopeId: id, - ...(label ? { label } : {}), - sessions: sessionN.get(id) ?? 0, - backgroundSessions: backgroundN.get(id) ?? 0, - lastActivity: lastActivityBy.get(id) ?? 0, - lastConversationActivity: lastConversationBy.get(id) ?? 0, - lastMessage: lastMessageBy.get(id) ?? "", - crons: cronN.get(id) ?? 0, - deployments: deployN.get(id) ?? 0, - skills: skillN.get(id) ?? 0, - })); + const scopes = [...labels].map(([id, label]) => { + const environment = environmentById.get(id); + const attachment = attachmentByScope.get(id); + return { + scopeId: id, + ...(label ? { label } : {}), + ...(environment?.name ? { environmentName: environment.name } : {}), + ...(attachment + ? { + environmentAttachment: { + environmentId: attachment.id, + environmentName: attachment.name, + }, + } + : {}), + sessions: sessionN.get(id) ?? 0, + backgroundSessions: backgroundN.get(id) ?? 0, + lastActivity: lastActivityBy.get(id) ?? 0, + lastConversationActivity: lastConversationBy.get(id) ?? 0, + lastMessage: lastMessageBy.get(id) ?? "", + crons: cronN.get(id) ?? 0, + deployments: deployN.get(id) ?? 0, + skills: skillN.get(id) ?? 0, + }; + }); scopes.sort( (a, b) => b.lastActivity - a.lastActivity || @@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { b.backgroundSessions - a.backgroundSessions || a.scopeId.localeCompare(b.scopeId), ); - return sendJson(res, 200, { scopeId: scope, scopes }); + return sendJson(res, 200, { scopeId: scope, scopes, environments }); +} + +interface ScopeEnvironmentMetadata { + environment?: { id: string; name: string; ownerActorId: string | null }; + environmentAttachment?: { environmentId: string; environmentName: string | null }; +} + +async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise { + const store = deps.environments; + if (!store) return {}; + + const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]); + const metadata: ScopeEnvironmentMetadata = {}; + if (environment?.name) { + metadata.environment = { + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + }; + } + if (attachment) { + const attachedEnvironment = await store.get(attachment.environmentId); + metadata.environmentAttachment = { + environmentId: attachment.environmentId, + environmentName: attachedEnvironment?.name ?? null, + }; + } + return metadata; } export async function getScopeConfig(ctx: ApiCtx): Promise { @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { if (!actor) return; await deps.config.refreshScope(targetScope); audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope }); + const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope); const serviceCredentials = await Promise.all( (deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => { const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? []; @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { }; return sendJson(res, 200, { scopeId: targetScope, + ...environmentMetadata, ...values, soulVersion: deps.config.soulVersion(targetScope), soulHistory: deps.config.soulHistory(targetScope), diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts index dcb280e50..b604750af 100644 --- a/test/admin-scopes-directory.test.ts +++ b/test/admin-scopes-directory.test.ts @@ -16,6 +16,8 @@ function start() { admin: built.admin, auditLog: built.auditLog, sessions: built.sessions, + config: built.config, + environments: built.environments, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na await s.close(); } }); + +test("the admin scope directory exposes named environments and attached scopes", async () => { + const s = start(); + try { + await s.built.app.upsertChannels([ + { channelId: "A", name: "source" }, + { channelId: "B", name: "attached" }, + ]); + await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" }); + await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" }); + + const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN })); + const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row])); + assert.deepEqual(directory.environments, [ + { id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] }, + ]); + assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent"); + assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + + const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN })); + assert.deepEqual(attached.environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + } finally { + await s.close(); + } +});