diff --git a/README.md b/README.md index fac9441a5..09d7b784f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is +- **AI Office zones.** Open **Training Campus & Main Office** from the ☰ menu (or **Zones** in `/lite`) to browse departments and their project floors. Existing projects start in Main Office / Software Engineering. Admins can add or edit departments and assign projects. Training Campus is currently an organizational view, not an execution sandbox; see [AI Office architecture](docs/ai-office-architecture.md). + - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎀**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. diff --git a/docs/ai-office-3d-modal.png b/docs/ai-office-3d-modal.png new file mode 100644 index 000000000..932880e32 Binary files /dev/null and b/docs/ai-office-3d-modal.png differ diff --git a/docs/ai-office-architecture.md b/docs/ai-office-architecture.md new file mode 100644 index 000000000..b09c19557 --- /dev/null +++ b/docs/ai-office-architecture.md @@ -0,0 +1,40 @@ +# AI Office architecture + +The first slice adds an organizational layer around the existing building. A floor still owns one project checkout and its workers, queue and boards. No worker or floor record is rewritten. + +``` +OfficeZone (training | main) + └── Department + └── Floor (project checkout) + └── Worker (running session) +``` + +## Boundaries + +- Zones and departments are persistent, versioned metadata owned by the office, not by a floor. Their defaults are installed once: Training Campus with its seven logical labs, and Main Office with its eleven departments. +- A floor has one department assignment. Existing floors without an assignment resolve to Main Office / Software Engineering. Assignment does not move a checkout or change its workers. +- Client navigation selects a zone and department and uses the existing `floor.go` path for travel. The selected zone is a browser view preference, not an execution boundary. +- The new HTTP API uses the existing signed session. Reads are available to signed-in users; only office admins can change department or floor assignments. The server derives the actor from the session. +- Provider, model, role, AgentProfile, worker and task remain distinct concepts. AgentProfiles, evaluation, promotion and routing belong to later slices. + +## Persistence and migration + +`zones.json` stores a schema version, zone/department definitions and floor assignments in the office data directory. The server validates it on load and writes a replacement file before renaming it into place. An absent file is initialized with defaults. Existing floors are resolved to Main Office / Software Engineering without editing `floors.json`; new and removed floors are reconciled by ID. Reloading does not duplicate defaults or assignments. + +Department and floor-assignment edits append an actor-derived audit record in the same atomic metadata write. The audit records are not exposed in the navigation response. + +Malformed or unsupported metadata must fail closed with a logged error. It must not overwrite the saved file or silently move a floor. Keep all original floor, worker, queue, account and worktree files untouched. + +## Security boundary + +Training Campus is an organizational view in this slice. Existing git worktrees isolate checkouts and branches, but all worker processes still run as the same OS user. Training agents must not be treated as sandboxed. The Sandbox Lab needs an execution boundary, credential separation, resource limits and enforced tool policy before untrusted agents run there. Promotion remains unavailable until evaluation, security review and human approval are implemented. + +## Delivery sequence + +1. Zones, departments, metadata store and legacy floor resolution. +2. Authenticated API and 3D/`/lite` navigation over the current floor travel path. +3. Persistent AgentProfiles and project stages. +4. Evaluation, promotion, approval and audit records. +5. Department orchestration and assisted routing. + +Each slice keeps the existing protocol and floor behavior valid and is verified with typecheck, tests, build and a browser screenshot for UI changes. diff --git a/docs/ai-office-lite.png b/docs/ai-office-lite.png new file mode 100644 index 000000000..b1b46944f Binary files /dev/null and b/docs/ai-office-lite.png differ diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..eab1d2fd4 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -48,6 +48,10 @@ They're in `core/registry.ts`, and each is a field of `ctx`. Every registration ## Server +### AI Office structure + +`src/server/office-structure.ts` owns the versioned Zone/Department metadata and resolves old floors to Main Office / Software Engineering without changing `floors.json`. `src/server/http/routes/structure.ts` exposes signed-in reads and admin-only edits. Types live in `src/shared/protocol/zones.ts` and `departments.ts`; the 3D and `/lite` views share `src/client/ui/structure.ts`. The data and later delivery phases are described in [AI Office architecture](ai-office-architecture.md). + - **`server.ts`** is the composition root: `startServer` builds the office's context a stage at a time, starts the hook server, opens the floors, and hands the context to the HTTP handler and the WebSocket. - **`office/`** is that context (`Ctx` in `office/context.ts`: the building-wide services, sending to browsers, the floors, the people in the office, going between floors, and the checks before something happens) and the pieces that make it. - **`ws/`** takes the sockets. Each domain's messages are handled in `ws/handlers/.ts`, a map from message type to handler that `satisfies HandlerMap`, and `ws/handlers/index.ts` puts them all in one `HandlerMap`: a message type with no handler, or a handler for a type that doesn't exist, fails the typecheck. The same file lists the features that keep something per person on a floor (`features`: what each lets go of when someone is `leaving` a floor, and once their socket has `closed`) and the pieces of what someone arriving on a floor is sent (`views`, one for each field of `FloorView`, put together by `office/views.ts`). diff --git a/docs/features.md b/docs/features.md index f320d9b99..d19dc3609 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,5 +1,7 @@ # Features +- **AI Office zones.** Training Campus and Main Office organize project floors by department. The zone browser works in 3D and `/lite`; admins configure departments and project assignments. Existing floors resolve to Main Office / Software Engineering. Training Campus does not yet isolate running workers; see [AI Office architecture](ai-office-architecture.md). + Everything in the office, room by room. Back to the [README](../README.md). - **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see (or type `owner/name`) and the office clones it into the workspace folder, `~/agent-office//` unless you pick another (**πŸ“ Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% Β· 231 MiB Β· 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **πŸ—‘** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..4875eb49c 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -18,6 +18,7 @@ import { toggleFloorMenu } from '../../ui/floormenu'; import { openHelp } from '../../ui/hud'; import { mountHud } from '../../ui/menu'; import { openServices } from '../../ui/services'; +import { openStructure } from '../../ui/structure'; import { openSettings, type SettingsPane } from '../../ui/settings'; import { needsSigningIn, openSignIns } from '../../ui/signins'; import { openTeam } from '../../ui/team'; @@ -69,6 +70,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { // The office has its bookshelf for them; a map of its own may not. { id: 'docs', icon: 'πŸ“š', label: 'Docs', section: 'Open', shown: () => !inOffice(), title: () => 'Read the project’s docs', run: parts.bookshelf.showBookshelf }, { id: 'elevator', icon: 'πŸ›—', label: () => (inOffice() ? 'Elevator' : 'Floors'), section: 'Open', count: () => store.floors.reduce((n, f) => n + (f.id === store.floor ? 0 : f.waiting), 0), title: () => (inOffice() ? 'Ride to another project' : 'Go to another project, or add one'), run: travel.showElevator }, + { id: 'zones', icon: '🏒', label: 'Training Campus & Main Office', section: 'Open', run: () => openStructure(travel.switchFloor) }, { id: 'roof', icon: '🍸', label: 'Rooftop bar', section: 'Open', shown: () => !core.upTop && inOffice() && builtFloors().length > 0, title: () => 'Ride the elevator up to the roof: a DJ, drinks and the city', run: () => travel.ride(ROOF) }, // In voice, V is push to talk, so leaving is only from here. { id: 'voice', icon: 'πŸŽ™οΈ', label: () => (voice.inVoice ? 'Leave voice' : 'Join voice'), section: 'Together', key: () => (voice.inVoice ? undefined : 'V'), on: () => voice.inVoice, blocked: noMedia, run: () => void talk.toggleVoice() }, diff --git a/src/client/lite.html b/src/client/lite.html index 50f7f23fe..0d74dad38 100644 --- a/src/client/lite.html +++ b/src/client/lite.html @@ -15,6 +15,7 @@ Floor + 🏒 3D
diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..2d36078cd 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -23,6 +23,7 @@ import { openQueue } from './ui/queue'; import { openAsk } from './ui/ask'; import { openMeeting, type MeetingPreset } from './ui/meeting'; import { openSignIns } from './ui/signins'; +import { openStructure } from './ui/structure'; import { modelBadge, providerLabel } from './ui/provider'; import { byUrgency, waitingInOrder, waitingLabel } from './nextup'; import { askNotifyPermission, DesktopNotifier, notifyPermission, waitingOnSomeone } from './notify'; @@ -95,6 +96,7 @@ function offTheRoof() { // ---- The floor you're on ------------------------------------------------------------------------ const floorSelect = $('floor') as HTMLSelectElement; +$('btn-zones').addEventListener('click', () => openStructure((floor) => net.send({ t: 'floor.go', floor }))); const floorLabel = (f: FloorInfo) => `${f.name}${f.cloning ? ` (${cloneLabel(f.clone)})` : f.waiting ? ` Β· πŸ™‹ ${f.waiting}` : ''}`; function renderFloors() { diff --git a/src/client/ui/structure.css b/src/client/ui/structure.css new file mode 100644 index 000000000..6420042f1 --- /dev/null +++ b/src/client/ui/structure.css @@ -0,0 +1,14 @@ +.structure-window { width: min(680px, 96vw); max-height: 88dvh; display: flex; flex-direction: column; } +.structure-window > header { display: flex; align-items: center; justify-content: space-between; } +.structure-body { overflow: auto; padding: 12px 18px 20px; display: grid; gap: 10px; } +.structure-tabs { display: flex; gap: 8px; flex-wrap: wrap; } +.structure-note { margin: 0; color: var(--muted); font-size: 13px; } +.structure-department { border: 2px solid var(--ink); border-left: 8px solid; border-radius: var(--radius); padding: 9px; display: grid; gap: 7px; background: #fff; } +.structure-department-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.structure-floor { width: 100%; justify-content: flex-start; } +.structure-empty { color: var(--muted); font-size: 13px; } +.structure-assign, .structure-add, .structure-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--ink); } +.structure-assign h3, .structure-add h3 { width: 100%; margin: 0; font-size: 15px; } +.structure-assign select, .structure-add input, .structure-edit input { min-height: 38px; } +.structure-editor { width: min(420px, 94vw); padding: 16px; } +.structure-edit label { display: flex; align-items: center; gap: 8px; } diff --git a/src/client/ui/structure.ts b/src/client/ui/structure.ts new file mode 100644 index 000000000..8587e5d40 --- /dev/null +++ b/src/client/ui/structure.ts @@ -0,0 +1,98 @@ +import './structure.css'; +import type { Department, OfficeStructure, OfficeZone } from '../../shared/protocol'; +import { store } from '../state'; +import { h, openModal, toast } from './dom'; + +const CHOICE = 'agent-office-zone'; + +async function structure(): Promise { + const response = await fetch('/api/zones', { cache: 'no-store' }); + if (!response.ok) throw new Error(response.status === 401 ? 'Sign in again' : 'Could not load office zones'); + return response.json() as Promise; +} + +async function mutate(url: string, method: 'POST' | 'PATCH', body: unknown): Promise { + const response = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const data = await response.json() as OfficeStructure & { error?: string }; + if (!response.ok) throw new Error(data.error ?? 'Could not save office structure'); + return data; +} + +export function selectedZone(zones: readonly OfficeZone[]): string { + const saved = localStorage.getItem(CHOICE); + return zones.some((z) => z.id === saved) ? saved! : 'main'; +} + +/** Zone and department navigation shared by 3D and /lite. Floor travel remains the existing path. */ +export function openStructure(go: (floorId: string) => void) { + const body = h('div.structure-body', {}, 'Loading office structure…'); + const panel = h('section.modal.structure-window', { 'aria-label': 'Office zones' }, h('header', {}, h('h2', {}, '🏒 AI Office')), body); + const modal = openModal(panel); + let data: OfficeStructure | undefined; + let zoneId = 'main'; + + const fail = (err: unknown) => toast((err as Error).message, 'error'); + const save = async (url: string, method: 'POST' | 'PATCH', value: unknown) => { + try { data = await mutate(url, method, value); render(); return true; } catch (err) { fail(err); return false; } + }; + + function editor(d: Department) { + const name = h('input', { type: 'text', value: d.name, maxlength: 100, 'aria-label': 'Department name' }) as HTMLInputElement; + const color = h('input', { type: 'color', value: d.color, 'aria-label': 'Department color' }) as HTMLInputElement; + const enabled = h('input', { type: 'checkbox', 'aria-label': 'Enabled' }) as HTMLInputElement; + enabled.checked = d.enabled; + enabled.disabled = d.id === 'software-engineering'; + const form = h('form.structure-edit', {}, h('label', {}, 'Name', name), h('label', {}, 'Color', color), h('label', {}, 'Enabled', enabled), h('button.btn.primary', { type: 'submit' }, 'Save')); + const editModal = openModal(h('section.modal.structure-editor', { 'aria-label': `Edit ${d.name}` }, h('header', {}, h('h2', {}, `Edit ${d.name}`)), form)); + form.addEventListener('submit', (event) => { + event.preventDefault(); + void save(`/api/departments/${encodeURIComponent(d.id)}`, 'PATCH', { name: name.value, color: color.value, enabled: enabled.checked }).then((ok) => { if (ok) editModal.close(); }); + }); + } + + function render() { + if (!data) return; + if (!data.zones.some((z) => z.id === zoneId)) zoneId = 'main'; + const zone = data.zones.find((z) => z.id === zoneId)!; + const tabs = h('nav.structure-tabs', { 'aria-label': 'Office zones' }, ...data.zones.map((z) => h('button.btn', { + type: 'button', 'aria-pressed': String(z.id === zoneId), class: z.id === zoneId ? 'primary' : '', + onclick: () => { zoneId = z.id; localStorage.setItem(CHOICE, zoneId); render(); }, + }, z.name))); + const departments = data.departments.filter((d) => d.zoneId === zoneId); + const sections = departments.map((d) => { + const floors = store.floors.filter((f) => data!.floorDepartments[f.id] === d.id); + const card = h('section.structure-department', { style: `border-left-color:${d.color}` }, + h('div.structure-department-head', {}, h('strong', {}, `${d.icon ?? '🏒'} ${d.name}`), + store.me.admin ? h('button.btn', { type: 'button', onclick: () => editor(d) }, 'Edit') : null), + ...floors.map((f) => h('button.btn.structure-floor', { type: 'button', disabled: !!f.cloning, onclick: () => { modal.close(); go(f.id); } }, f.name)), + floors.length ? null : h('span.structure-empty', {}, 'No projects yet'), + ); + return card; + }); + const assign = store.me.admin && store.floors.length ? assignment(data) : null; + const add = store.me.admin ? addDepartment() : null; + body.replaceChildren(tabs, h('p.structure-note', {}, zone.type === 'training' ? 'Training Campus is organizational only. Workers are not sandboxed yet.' : 'Main Office projects and departments.'), ...sections, ...(assign ? [assign] : []), ...(add ? [add] : [])); + } + + function assignment(view: OfficeStructure) { + const floors = h('select', { 'aria-label': 'Project' }) as HTMLSelectElement; + floors.replaceChildren(...store.floors.filter((f) => !f.cloning).map((f) => h('option', { value: f.id }, f.name))); + const depts = h('select', { 'aria-label': 'Department' }) as HTMLSelectElement; + depts.replaceChildren(...view.departments.filter((d) => d.enabled).map((d) => h('option', { value: d.id }, `${d.zoneId}: ${d.name}`))); + floors.addEventListener('change', () => { depts.value = view.floorDepartments[floors.value] ?? 'software-engineering'; }); + depts.value = view.floorDepartments[floors.value] ?? 'software-engineering'; + const form = h('form.structure-assign', {}, h('h3', {}, 'Assign project'), floors, depts, h('button.btn.primary', { type: 'submit' }, 'Assign')); + form.addEventListener('submit', (event) => { event.preventDefault(); void save('/api/floor-department', 'POST', { floorId: floors.value, departmentId: depts.value }); }); + return form; + } + + function addDepartment() { + const slug = h('input', { type: 'text', placeholder: 'slug', required: true, maxlength: 40, pattern: '[a-z0-9][a-z0-9-]*', 'aria-label': 'Department slug' }) as HTMLInputElement; + const name = h('input', { type: 'text', placeholder: 'Name', required: true, maxlength: 100, 'aria-label': 'Department name' }) as HTMLInputElement; + const form = h('form.structure-add', {}, h('h3', {}, 'Add department'), slug, name, h('button.btn', { type: 'submit' }, 'Add')); + form.addEventListener('submit', (event) => { event.preventDefault(); void save('/api/departments', 'POST', { zoneId, slug: slug.value, name: name.value, color: zoneId === 'training' ? '#76a88a' : '#6175b5' }); }); + return form; + } + + void structure().then((view) => { if (!modal.backdrop.isConnected) return; data = view; zoneId = selectedZone(view.zones); render(); }).catch((err) => { body.textContent = (err as Error).message; }); +} diff --git a/src/server/http/router.ts b/src/server/http/router.ts index 42d82b2da..6b0fa18c2 100644 --- a/src/server/http/router.ts +++ b/src/server/http/router.ts @@ -18,7 +18,7 @@ type Where = { path: string | readonly string[]; prefix?: never } | { prefix: st interface Answers { /** Only requests with this method; any method when missing, and the route answers the rest itself. */ - method?: 'GET' | 'POST'; + method?: 'GET' | 'POST' | 'PATCH'; } /** diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..39c50d8ee 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { structureRoutes } from './structure.js'; export const routes: readonly Route[] = [ // Anyone. @@ -27,6 +28,11 @@ export const routes: readonly Route[] = [ pageRoutes.favicon, // Signed in. authRoutes.whoami, + structureRoutes.zones, + structureRoutes.departments, + structureRoutes.addDepartment, + structureRoutes.updateDepartment, + structureRoutes.assignFloor, agentRoutes.models, fileRoutes.image, fileRoutes.whiteboardFile, diff --git a/src/server/http/routes/structure.ts b/src/server/http/routes/structure.ts new file mode 100644 index 000000000..22954828c --- /dev/null +++ b/src/server/http/routes/structure.ts @@ -0,0 +1,30 @@ +import type { Route } from '../router.js'; +import { readBody, sameOrigin, send } from '../util.js'; +import type { Ctx } from '../../office/context.js'; + +async function edit(ctx: Ctx, req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, accountId: string | undefined, action: (body: unknown) => string | undefined) { + if (!ctx.meOf(accountId).admin) return send(res, 403, { error: 'Only admins can configure the office' }); + if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Origin mismatch' }); + let body: unknown; + try { body = JSON.parse(await readBody(req, 8192)); } catch { return send(res, 400, { error: 'Invalid JSON' }); } + const error = action(body); + if (error) return send(res, 400, { error }); + return send(res, 200, ctx.structure.snapshot()); +} + +export const structureRoutes = { + zones: { method: 'GET', path: '/api/zones', auth: 'session', handle: (ctx, { res }) => send(res, 200, ctx.structure.snapshot()) }, + departments: { method: 'GET', path: '/api/departments', auth: 'session', handle: (ctx, { res }) => send(res, 200, ctx.structure.snapshot().departments) }, + addDepartment: { method: 'POST', path: '/api/departments', auth: 'session', handle: (ctx, { req, res, session }) => edit(ctx, req, res, session.account?.id, (body) => ctx.structure.addDepartment(body, session.account?.id ?? 'shared-password-admin')) }, + updateDepartment: { + method: 'PATCH', prefix: '/api/departments/', auth: 'session', + handle: (ctx, { req, res, path, session }) => edit(ctx, req, res, session.account?.id, (body) => ctx.structure.updateDepartment(path.slice('/api/departments/'.length), body, session.account?.id ?? 'shared-password-admin')), + }, + assignFloor: { + method: 'POST', path: '/api/floor-department', auth: 'session', + handle: (ctx, { req, res, session }) => edit(ctx, req, res, session.account?.id, (body) => { + if (!body || typeof body !== 'object' || !('floorId' in body) || !('departmentId' in body) || typeof body.floorId !== 'string' || typeof body.departmentId !== 'string') return 'Invalid assignment'; + return ctx.structure.assignFloor(body.floorId, body.departmentId, session.account?.id ?? 'shared-password-admin'); + }), + }, +} satisfies Record; diff --git a/src/server/office-structure.ts b/src/server/office-structure.ts new file mode 100644 index 000000000..5bde77542 --- /dev/null +++ b/src/server/office-structure.ts @@ -0,0 +1,165 @@ +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Department, OfficeStructure, OfficeZone } from '../shared/protocol.js'; +import type { Building } from './building.js'; + +const SCHEMA = 1; +export const MAIN_ENGINEERING = 'software-engineering'; +const names = [ + ['executive', 'Executive Center', 'πŸ›οΈ'], + [MAIN_ENGINEERING, 'Software Engineering', 'πŸ’»'], + ['product', 'Product Management', '🧭'], + ['research-data', 'Research & Data', 'πŸ”¬'], + ['design-media', 'Design & Media', '🎨'], + ['marketing', 'Marketing', 'πŸ“£'], + ['sales-support', 'Sales & Customer Support', '🀝'], + ['finance', 'Finance', 'πŸ’°'], + ['legal', 'Legal & Compliance', 'βš–οΈ'], + ['operations', 'Operations', 'βš™οΈ'], + ['knowledge', 'Knowledge Center', 'πŸ“š'], +] as const; +const labs = [ + ['academy', 'Agent Academy', 'πŸŽ“'], + ['incubator', 'Project Incubator', '🌱'], + ['sandbox', 'Sandbox Lab', 'πŸ§ͺ'], + ['evaluation', 'Evaluation Lab', 'πŸ“Š'], + ['workflow', 'Workflow Lab', 'πŸ”€'], + ['skills', 'Skill Workshop', 'πŸ› οΈ'], + ['graduation', 'Graduation Gate', 'πŸšͺ'], +] as const; + +function defaults(): OfficeStructure { + const departments: Department[] = [ + ...names.map(([slug, name, icon]) => ({ id: slug, slug, name, icon, zoneId: 'main', color: '#6175b5', enabled: true })), + ...labs.map(([slug, name, icon]) => ({ id: `training-${slug}`, slug, name, icon, zoneId: 'training', color: '#76a88a', enabled: true })), + ]; + const zones: OfficeZone[] = [ + { id: 'training', type: 'training', name: 'Training Campus', order: 0, departmentIds: departments.filter((d) => d.zoneId === 'training').map((d) => d.id) }, + { id: 'main', type: 'main', name: 'Main Office', order: 1, departmentIds: departments.filter((d) => d.zoneId === 'main').map((d) => d.id) }, + ]; + return { zones, departments, floorDepartments: {} }; +} + +const idOk = (v: unknown): v is string => typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(v); +const colorOk = (v: unknown): v is string => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v); +const record = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +interface AuditEvent { at: number; actor: string; action: string; target: string } +type StoredStructure = OfficeStructure & { audit: AuditEvent[] }; + +function parse(input: unknown): StoredStructure { + if (!record(input) || input.schemaVersion !== SCHEMA || !record(input.data)) throw new Error('Unsupported structure schema'); + const { zones, departments, floorDepartments, audit } = input.data; + if (!Array.isArray(zones) || !Array.isArray(departments) || !record(floorDepartments)) throw new Error('Invalid structure data'); + const zIds = new Set(); + const dIds = new Set(); + const slugs = new Set(); + for (const z of zones) { + if (!record(z) || !idOk(z.id) || zIds.has(z.id) || (z.type !== 'main' && z.type !== 'training') || typeof z.name !== 'string' || !Number.isInteger(z.order) || !Array.isArray(z.departmentIds)) throw new Error('Invalid zone'); + zIds.add(z.id); + } + if (!zIds.has('main') || !zIds.has('training')) throw new Error('Default zones missing'); + for (const d of departments) { + if (!record(d) || !idOk(d.id) || dIds.has(d.id) || !idOk(d.slug) || typeof d.name !== 'string' || !colorOk(d.color) || typeof d.enabled !== 'boolean' || typeof d.zoneId !== 'string' || !zIds.has(d.zoneId)) throw new Error('Invalid department'); + const key = `${d.zoneId}/${d.slug}`; + if (slugs.has(key)) throw new Error('Duplicate department slug'); + dIds.add(d.id); + slugs.add(key); + } + if (!dIds.has(MAIN_ENGINEERING)) throw new Error('Default engineering department missing'); + for (const z of zones) { + if ((z.departmentIds as unknown[]).some((id) => typeof id !== 'string' || !dIds.has(id) || departments.find((d) => d.id === id)?.zoneId !== z.id)) throw new Error('Invalid zone departments'); + } + for (const [floorId, deptId] of Object.entries(floorDepartments)) if (!idOk(floorId) || typeof deptId !== 'string' || !dIds.has(deptId)) throw new Error('Invalid floor assignment'); + if (audit !== undefined && (!Array.isArray(audit) || audit.some((e) => !record(e) || !Number.isFinite(e.at) || typeof e.actor !== 'string' || typeof e.action !== 'string' || typeof e.target !== 'string'))) throw new Error('Invalid audit record'); + return { zones: zones as OfficeZone[], departments: departments as Department[], floorDepartments: floorDepartments as Record, audit: (audit ?? []) as AuditEvent[] }; +} + +/** Organizational metadata around Building. It never edits floors.json or worker state. */ +export class OfficeStructureStore { + private file: string; + private data: StoredStructure = { ...defaults(), audit: [] }; + private writable = true; + + constructor(dataDir: string, private building: Pick) { + this.file = path.join(dataDir, 'zones.json'); + if (!existsSync(this.file)) { + this.save(); + } else { + try { + this.data = parse(JSON.parse(readFileSync(this.file, 'utf8'))); + } catch (err) { + this.writable = false; + console.error(`agent-office: ${this.file} couldn't be loaded; structure edits disabled: ${(err as Error).message}`); + } + } + } + + snapshot(): OfficeStructure { + const floorDepartments = Object.fromEntries(this.building.list().map((f) => [f.id, this.data.floorDepartments[f.id] ?? MAIN_ENGINEERING])); + return { zones: this.data.zones.map((z) => ({ ...z, departmentIds: [...z.departmentIds] })), departments: this.data.departments.map((d) => ({ ...d })), floorDepartments }; + } + + addDepartment(input: unknown, actor = 'system'): string | undefined { + if (!this.writable) return 'Office structure is read-only until zones.json is repaired'; + if (!record(input) || !idOk(input.slug) || typeof input.name !== 'string' || !input.name.trim() || input.name.length > 100 || typeof input.zoneId !== 'string' || !colorOk(input.color)) return 'Invalid department'; + if (!this.data.zones.some((z) => z.id === input.zoneId)) return 'Unknown zone'; + if (this.data.departments.some((d) => d.id === input.slug || (d.zoneId === input.zoneId && d.slug === input.slug))) return 'Department slug already exists'; + const d: Department = { id: input.slug, slug: input.slug, name: input.name.trim(), zoneId: input.zoneId, color: input.color, enabled: true }; + const next = structuredClone(this.data); + next.departments.push(d); + next.zones.find((z) => z.id === d.zoneId)!.departmentIds.push(d.id); + return this.commit(next, { at: Date.now(), actor, action: 'department.add', target: d.id }); + } + + updateDepartment(id: string, input: unknown, actor = 'system'): string | undefined { + if (!this.writable) return 'Office structure is read-only until zones.json is repaired'; + if (!record(input)) return 'Invalid department'; + const next = structuredClone(this.data); + const d = next.departments.find((item) => item.id === id); + if (!d) return 'Unknown department'; + if (input.name !== undefined) { + if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 100) return 'Invalid name'; + d.name = input.name.trim(); + } + if (input.color !== undefined) { + if (!colorOk(input.color)) return 'Invalid color'; + d.color = input.color; + } + if (input.enabled !== undefined) { + if (typeof input.enabled !== 'boolean' || (id === MAIN_ENGINEERING && !input.enabled)) return 'Invalid enabled state'; + d.enabled = input.enabled; + } + return this.commit(next, { at: Date.now(), actor, action: 'department.update', target: id }); + } + + assignFloor(floorId: string, departmentId: string, actor = 'system'): string | undefined { + if (!this.writable) return 'Office structure is read-only until zones.json is repaired'; + if (!this.building.list().some((f) => f.id === floorId)) return 'Unknown floor'; + if (!this.data.departments.some((d) => d.id === departmentId && d.enabled)) return 'Unknown or disabled department'; + const next = structuredClone(this.data); + if (departmentId === MAIN_ENGINEERING) delete next.floorDepartments[floorId]; + else next.floorDepartments[floorId] = departmentId; + return this.commit(next, { at: Date.now(), actor, action: 'floor.assign', target: floorId }); + } + + private commit(next: StoredStructure, event: AuditEvent): string | undefined { + const previous = this.data; + next.audit.push(event); + this.data = next; + const error = this.save(); + if (error) this.data = previous; + return error; + } + + private save(): string | undefined { + const tmp = `${this.file}.${process.pid}.tmp`; + try { + writeFileSync(tmp, JSON.stringify({ schemaVersion: SCHEMA, data: this.data }, null, 2), { mode: 0o600 }); + renameSync(tmp, this.file); + } catch (err) { + try { rmSync(tmp, { force: true }); } catch { /* keep original error */ } + return `Could not save office structure: ${(err as Error).message}`; + } + return undefined; + } +} diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..1d6398a6c 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -17,6 +17,7 @@ import type { PlanLimitsReader } from '../limits.js'; import type { Webhook } from '../webhook.js'; import type { Machine } from '../machine.js'; import type { Building, FloorDef } from '../building.js'; +import type { OfficeStructureStore } from '../office-structure.js'; import type { Floor } from '../floor.js'; import type { Sky } from '../sky.js'; import type { Themes } from '../theme.js'; @@ -51,6 +52,7 @@ export interface Core { models: Partial>; /** The building: a floor per project, each with its own workers, boards and queue. */ building: Building; + structure: OfficeStructureStore; floors: Map; } diff --git a/src/server/office/core.ts b/src/server/office/core.ts index 47ec9f80c..88c168805 100644 --- a/src/server/office/core.ts +++ b/src/server/office/core.ts @@ -6,6 +6,7 @@ import { providerCommand } from '../agents.js'; import type { AgentProvider } from '../../shared/providers.js'; import { createModelCatalogues } from '../models.js'; import { Building } from '../building.js'; +import { OfficeStructureStore } from '../office-structure.js'; import type { Floor } from '../floor.js'; import { ChatLog } from '../history.js'; import { Arcade, HighScores } from '../cabinet.js'; @@ -39,10 +40,11 @@ export function createCore(ctx: Ctx, cfg: Config, publicDir: string): Core { // --- The building: a floor per project, each with its own workers, boards and queue ----------- const building = new Building(cfg.dataDir, cfg.projectsDir); + const structure = new OfficeStructureStore(cfg.dataDir, building); if (cfg.projects) { const err = building.setProjectsDir(cfg.projects, 'the command line'); if (err) console.error(`agent-office: --projects: ${err}`); } const floors = new Map(); - return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors }; + return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, structure, floors }; } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..0d4ab75fe 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -30,6 +30,8 @@ export * from './protocol/settings.js'; export * from './protocol/toys.js'; export * from './protocol/usage.js'; export * from './protocol/workers.js'; +export * from './protocol/zones.js'; +export * from './protocol/departments.js'; export type ClientMsg = | PresenceClientMsg diff --git a/src/shared/protocol/departments.ts b/src/shared/protocol/departments.ts new file mode 100644 index 000000000..f5b0434de --- /dev/null +++ b/src/shared/protocol/departments.ts @@ -0,0 +1,10 @@ +export interface Department { + id: string; + zoneId: string; + name: string; + slug: string; + description?: string; + color: string; + icon?: string; + enabled: boolean; +} diff --git a/src/shared/protocol/zones.ts b/src/shared/protocol/zones.ts new file mode 100644 index 000000000..483033910 --- /dev/null +++ b/src/shared/protocol/zones.ts @@ -0,0 +1,17 @@ +export type OfficeZoneType = 'training' | 'main'; + +export interface OfficeZone { + id: string; + type: OfficeZoneType; + name: string; + description?: string; + order: number; + departmentIds: string[]; +} + +export interface OfficeStructure { + zones: OfficeZone[]; + departments: import('./departments.js').Department[]; + /** Floor IDs map to a department. Missing IDs belong to Main Office / Software Engineering. */ + floorDepartments: Record; +} diff --git a/tests/client-registry.test.ts b/tests/client-registry.test.ts index 09487039e..f4e1c8b22 100644 --- a/tests/client-registry.test.ts +++ b/tests/client-registry.test.ts @@ -379,7 +379,7 @@ function interactKinds(): { kind: string; file: string }[] { } } // Declared any other way, the scan above wouldn't see it. - assert.equal([...src.matchAll(/\bInteractKinds\b/g)].length - [...src.matchAll(/interface InteractKinds\b/g)].length, file === 'world/types.ts' ? 1 : 0, `${file} names InteractKinds other than to add kinds to it`); + assert.equal([...src.matchAll(/\bInteractKinds\b/g)].length - [...src.matchAll(/interface InteractKinds\b/g)].length, file.replaceAll('\\', '/') === 'world/types.ts' ? 1 : 0, `${file} names InteractKinds other than to add kinds to it`); } return out; } diff --git a/tests/office-structure.test.ts b/tests/office-structure.test.ts new file mode 100644 index 000000000..ff280c849 --- /dev/null +++ b/tests/office-structure.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { OfficeStructureStore, MAIN_ENGINEERING } from '../src/server/office-structure.js'; + +function setup(t: { after(fn: () => void): void }) { + const dir = mkdtempSync(path.join(tmpdir(), 'agent-office-zones-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const floors = [{ id: 'old-project' }]; + const building = { list: () => floors }; + return { dir, floors, building }; +} + +test('defaults are created once and legacy floors resolve to main engineering without changing floors.json', (t) => { + const { dir, building } = setup(t); + const old = '[{"id":"old-project"}]'; + writeFileSync(path.join(dir, 'floors.json'), old); + const first = new OfficeStructureStore(dir, building); + const saved = readFileSync(path.join(dir, 'zones.json'), 'utf8'); + assert.deepEqual(first.snapshot().zones.map((z) => z.id), ['training', 'main']); + assert.equal(first.snapshot().departments.length, 18); + assert.equal(first.snapshot().floorDepartments['old-project'], MAIN_ENGINEERING); + new OfficeStructureStore(dir, building); + assert.equal(readFileSync(path.join(dir, 'zones.json'), 'utf8'), saved); + assert.equal(readFileSync(path.join(dir, 'floors.json'), 'utf8'), old); +}); + +test('department assignments survive restart and removed floors disappear from the view', (t) => { + const { dir, building, floors } = setup(t); + const first = new OfficeStructureStore(dir, building); + assert.equal(first.assignFloor('old-project', 'training-incubator'), undefined); + assert.equal(new OfficeStructureStore(dir, building).snapshot().floorDepartments['old-project'], 'training-incubator'); + floors.length = 0; + assert.deepEqual(new OfficeStructureStore(dir, building).snapshot().floorDepartments, {}); +}); + +test('department creation validates slug uniqueness and unknown assignment', (t) => { + const { dir, building } = setup(t); + const office = new OfficeStructureStore(dir, building); + const input = { zoneId: 'main', slug: 'quality', name: 'Quality', color: '#123456' }; + assert.equal(office.addDepartment(input), undefined); + assert.match(office.addDepartment(input) ?? '', /already exists/); + assert.equal(office.assignFloor('old-project', 'missing'), 'Unknown or disabled department'); + assert.equal(office.snapshot().departments.filter((d) => d.id === 'quality').length, 1); + const saved = JSON.parse(readFileSync(path.join(dir, 'zones.json'), 'utf8')); + assert.equal(saved.data.audit[0].action, 'department.add'); +}); + +test('malformed metadata remains untouched and disables edits', (t) => { + const { dir, building } = setup(t); + const file = path.join(dir, 'zones.json'); + writeFileSync(file, '{broken'); + const office = new OfficeStructureStore(dir, building); + assert.match(office.addDepartment({}) ?? '', /read-only/); + assert.equal(readFileSync(file, 'utf8'), '{broken'); + assert.equal(office.snapshot().floorDepartments['old-project'], MAIN_ENGINEERING); +});