openvibers/openvibe.space Commits
The builder is the person's own agent: a space is a task of OpenVibe.Actor (#226)
Changes
@@@ -43,6 +43,13 @@ DATABASE_DIRECT_URL=SPACE_PUBLIC_HOSTING=off# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).#SPACE_AI=on++# Who builds a space when a person describes one (plan 2026-10-22 §Z, stage Z1).++# legacy Space's own AI loop (the way it was)++# agent a task of the person's own agent (OpenVibe.Actor), with the space_* tools++# The old path stays until the new one is proven; nothing is deleted.++#SPACE_BUILDER=legacy++# OpenVibe.Actor, the person's one agent (the task Space starts, and the tools it works with).++#OV_ACTOR_INTERNAL_URL=http://127.0.0.1:4950# OpenVibe.Events → Space (account export and deletion, server/account-data.js): signs deliveries to /internal/events (32+ characters).#SPACE_EVENTS_SECRET=#OV_AI_INTERNAL_URL=http://127.0.0.1:4700@@@ -2,6 +2,7 @@## Unreleased++- **The builder is the person's own agent** (plan 2026-10-22 §Z, stage Z1): "Describe it and OpenVibe AI builds it" starts a task of the person's one agent (OpenVibe.Actor) instead of Space's own AI loop. The space is the task's subject; the agent writes its files through Space's builder API (`/api/v1/builder/spaces`, the capabilities `space.build.read|create|write|publish`), and the editor opens the Dock at that task and shows the space as the agent writes it. The step reaches the network's agent namespace (`agent.step.started|updated|ended`, product `space`), so the Dock's one timeline shows it. Publishing is never done on its own: the agent asks, the person's answer lands in the Dock ("Needs you") and nothing goes live until they say yes (`POST /api/v1/agent/approvals/:id/decide`). The old builder stays behind `SPACE_BUILDER=legacy` until the new one is proven; nothing is deleted. `test/spaces-agent.test.js`.- **The forum's links follow Community's move to `/forum`** (design 2026-11-07): Space's forum redirect nowmaps `/s` and `/s/*` straight to `/forum` and `/forum/<space>/<thread>` (the `/t/` segment is gone) insteadof passing the old path on, so a bookmark lands in one hop. The home page, both navbars and `llms.txt` link@@@ -6,15 +6,19 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)","port": 4940,"domain": "openvibe.space",-- "contracts": "openvibe-contracts v0.312.0",++ "contracts": "openvibe-contracts v0.324.0","sdk": "openvibe-sdk v0.94.0",-- "shared": "openvibe-shared v4.133.0",++ "shared": "openvibe-shared v4.134.0","capabilities": ["space.site.manage","space.site.config","space.deploy.create","space.domain.manage",-- "space.resource.read"++ "space.resource.read",++ "space.build.read",++ "space.build.create",++ "space.build.write",++ "space.build.publish"],"capabilitiesRegistered": true,"deployed": true,@@@ -37,5 +41,10 @@"No app or agent holds a space.* grant on OpenVibe.Network yet; staff use the dashboard"],"hostingDeployed": false,-- "updated": "2026-11-07"++ "updated": "2026-11-07",++ "packages": [++ "openvibe-contracts v0.324.0",++ "openvibe-sdk v0.94.0",++ "openvibe-shared v4.134.0"++ ]}nnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: expand++-- Space's builder as a task of the person's one agent (plan 2026-10-22 section Z, stage Z1).++--++-- A space may carry the task of the person's agent that builds it: the task's id (Actor's tsk_…), what the task is++-- doing (running, done, failed), the step it shows in the Dock's one timeline, and the publish request waiting for++-- the person's answer. Times are epoch milliseconds like the other space tables.++ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_task_id text COLLATE "C";++ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_task_state text COLLATE "C";++ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_step_id text COLLATE "C";++ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_publish_id text COLLATE "C";++CREATE INDEX IF NOT EXISTS space_apps_agent_task ON space_apps (agent_task_id) WHERE agent_task_id IS NOT NULL;++++-- A publish the agent asked for. The person answers it in the Dock ("Needs you") or on the space's page; nothing++-- goes live until they do. The row is kept after the answer, so the space's history says what was published and when.++CREATE TABLE IF NOT EXISTS space_publish_requests (++ id text COLLATE "C" PRIMARY KEY, -- apr_<ULID>++ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,++ owner_subject text COLLATE "C" NOT NULL, -- usr_…++ state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','approved','declined','failed')),++ created_at bigint NOT NULL,++ decided_at bigint++);++CREATE INDEX IF NOT EXISTS space_publish_requests_pending ON space_publish_requests (owner_subject, state) WHERE state = 'pending';@@@ -15,9 +15,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0","pg": "^8.23.0","quickjs-emscripten": "0.32.0"},@@@ -1122,9 +1122,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.324.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",++ "integrity": "sha512-fSeabmHcX74SxVKEfGXMgWhEDugYQh5KWB+5Vj86CTvr2q50e/7AfHBDtVVw2WYQ06wiH1wN0hctDYoc2DQi9Q==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1166,9 +1166,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.133.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",-- "integrity": "sha512-ry+yvht0O+fHhMQr2LOaQKRYJo38d2cgsI3UtRmHge91qEQ/gL0wtcHawME1/G7V0NrlpdGzJMueKYd8UKrhjw==",++ "version": "4.134.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0",++ "integrity": "sha512-uCqBvpgWXrcZo1EEBD2T6QC+CoNd1BV81MLnj156fBLJk+fWmCJI6QPY17iQGtPdFMg0b5k7wVfYrg6n/o8dhw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -27,9 +27,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0","pg": "^8.23.0","quickjs-emscripten": "0.32.0"},@@@ -147,8 +147,7 @@// ── AI changes ──var overlay = $('ed-building');function building(on, text) { overlay.hidden = !on; $('ed-building-text').textContent = text ? '“' + text + '”' : ''; }-- function poll() {-- call('GET', '/ai').then(function (j) {++ function poll() { call('GET', '/ai').then(function (j) {if (j.status === 'running') return setTimeout(poll, 3000);building(false);if (j.status === 'done') {@@@ -165,12 +164,60 @@var input = $('ed-ai'), prompt = input.value.trim();if (!prompt) return;var go = function () {-- return call('POST', '/ai', { prompt: prompt }).then(function () { input.value = ''; building(true, prompt); setTimeout(poll, 3000); }, function (err) { status(err.message, 'bad'); });++ return call('POST', '/ai', { prompt: prompt }).then(function (j) {++ input.value = '';++ building(true, prompt);++ // The answer names a task of the person's own agent (plan 2026-10-22 §Z stage Z1): follow it there.++ if (j && j.task) { agent = { task: j.task, state: 'running' }; var title = $('ed-building-title'); if (title) title.textContent = 'Your agent is building this…'; openDock(); setTimeout(agentPoll, 1500); return; }++ setTimeout(poll, 3000);++ }, function (err) { status(err.message, 'bad'); });};if (dirty) save().then(go, function () {}); else go();});if (space.building) { building(true, space.building_prompt); setTimeout(poll, 1500); }++ // ── The person's own agent (plan 2026-10-22 §Z, stage Z1) ──++ // The space is a task of the agent: the Dock opens at it, the page follows it, and the preview shows the++ // space as the agent writes it. Nothing new is drawn here: the Dock and the editor's own preview do the work.++ var agent = boot.agent || null;++ function openDock() {++ try { if (window.OpenVibeNavbar && agent && agent.task) window.OpenVibeNavbar.openDock('agent', { task: agent.task }); } catch (e) { /* the Dock is optional */ }++ }++ /** The files the agent wrote: they replace the editor's, unless the person has unsaved edits of their own. */++ function agentFiles(list) {++ if (!list || !list.length || dirty) return false;++ var next = list.map(function (f) { return { path: f.path, content: f.content }; });++ var same = next.length === files.length && next.every(function (f, i) { return f.path === files[i].path && f.content === files[i].content; });++ if (same) return false;++ files = next;++ current = 'index.html';++ setText((fileOf('index.html') || { content: '' }).content, 'index.html');++ renderTabs();++ run();++ return true;++ }++ function agentPoll() {++ if (!agent) return;++ call('GET', '/agent').then(function (j) {++ agentFiles(j.files);++ if (j.publish && j.publish.state === 'pending') status('Your agent wants to publish this space — answer it in your Dock');++ if (j.status === 'running' || j.status === 'waiting') { setTimeout(agentPoll, 3000); return; }++ building(false);++ if (j.status === 'done') status(j.message ? 'Your agent is done: ' + j.message : 'Your agent is done');++ else if (j.status === 'failed') status('Your agent could not build this space', 'bad');++ else status('');++ }, function () { setTimeout(agentPoll, 5000); });++ }++ if (agent) {++ var title = $('ed-building-title');++ if (title) title.textContent = 'Your agent is building this…';++ if (agent.state === 'running' || agent.state === 'waiting') {++ building(true, '');++ openDock();++ setTimeout(agentPoll, 1500);++ }++ }++// ── Settings ──var dialog = $('ed-dialog'), form = $('ed-settings-form');$('ed-settings').addEventListener('click', function () {@@@ -9,6 +9,10 @@* space.site.config read, set and reset a site's response headers, redirects and SPA fallback* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)* space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)++ * space.build.read the person's own spaces and their files, as their agent works on them (plan 2026-10-22 §Z, stage Z1)++ * space.build.create make a new space for the person (their agent, OpenVibe.Actor)++ * space.build.write write the files of one of the person's own spaces++ * space.build.publish ask to publish one of them; the person answers before anything goes live** A service token is judged by its capability AND by the project role of the principal it acts* as (domain/access.js). Browsers are judged by their role alone.@@@ -24,6 +28,11 @@ const CAPABILITIES = Object.freeze({SITE_CONFIG: 'space.site.config',RESOURCE_READ: 'space.resource.read',RESOURCE_CONTROL: 'space.resource.control',++ // The builder as the person's one agent works on it (plan 2026-10-22 §Z, stage Z1)++ BUILD_READ: 'space.build.read',++ BUILD_CREATE: 'space.build.create',++ BUILD_WRITE: 'space.build.write',++ BUILD_PUBLISH: 'space.build.publish',});/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */@@@ -9,6 +9,15 @@* space.domain.verified a custom domain's TXT record was found; the domain is now served* space.app.claimed a guest draft was claimed by a signed-in person (it is theirs now)*++ * agent.step.started the person's agent started a step on one of their spaces (plan 2026-10-22 §Z, stage Z1)++ * agent.step.updated the same step, brought forward++ * agent.step.ended the step ended++ *++ * The agent.* namespace belongs to the network (like provider.*), so a first-party mode may publish into it:++ * OpenVibe.Actor keeps the one timeline behind the Dock, and every mode (Run, Space, …) publishes its own steps++ * with its own product. Space's manifest does not list them (one producer per event type, contracts' rule):++ * Actor owns the family.++ ** The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db* transaction), so an event exists if and only if its change committed. The relay publishes with* Space's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and@@@ -17,7 +26,8 @@*/const { createServiceOutbox } = require('openvibe-sdk/events');--const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed']);++const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed',++ 'agent.step.started', 'agent.step.updated', 'agent.step.ended']);function createHostOutbox({ db, config, fetchImpl, now, log = console }) {return createServiceOutbox({@@@ -150,6 +150,21 @@ function load(env = process.env) {sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },++ // The builder (plan 2026-10-22 section Z, stage Z1): who builds a space when the person describes one.++ // 'legacy' Space's own AI loop (the way it was before the agent became the builder)++ // 'agent' a task of the person's one agent (OpenVibe.Actor), with the space_* tools++ // The old path stays until the new one is proven; nothing is deleted. Production runs 'agent' once the++ // checks pass (SPACE_BUILDER=agent in /etc/openvibe/space.env).++ builder: {++ mode: ['agent', 'legacy'].includes(String(env.SPACE_BUILDER || 'legacy').trim().toLowerCase())++ ? String(env.SPACE_BUILDER || 'legacy').trim().toLowerCase() : 'legacy',++ // OpenVibe.Actor, the person's one agent (its API answers the task and the tools it uses).++ actorUrl: trim(env.OV_ACTOR_INTERNAL_URL || env.OV_ACTOR_URL || 'http://127.0.0.1:4950'),++ timeoutMs: int(env.SPACE_ACTOR_TIMEOUT_MS, 15000),++ // How often Space asks Actor how the task is doing (the agent's steps reach the Dock through Events).++ pollMs: int(env.SPACE_ACTOR_POLL_MS, 3000),++ },++// Spaces (server/spaces/): apps people make here. Making, editing, previewing and the AI builder are open to// everyone signed in; publishing follows publicHosting (or staff). The limits keep one person or one space from// filling the database or spending everyone's AI.@@@ -72,6 +72,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });++ // The person's one agent (plan 2026-10-22 §Z, stage Z1): the builder API the agent works through, the++ // steps it publishes into the network's agent namespace, and the watcher that follows its task.++ ctx.spaceSteps = require('./spaces/steps').createSpaceSteps({ outbox, config, log });++ ctx.spaceAgent = require('./spaces/agent').createSpaceAgent({ config, fetchImpl, log });++ ctx.spaceAgentWatch = require('./spaces/agent-watch').createAgentWatcher({ config, spaces: ctx.spaces, agent: ctx.spaceAgent, steps: ctx.spaceSteps, log });// Guest drafts (openvibe.website, plan 5.9): the job that removes the drafts whose week is up.ctx.guestCleanup = require('./spaces/guest-cleanup').createGuestCleanup({ store, config, log });{@@@ -147,6 +152,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fapp.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));}app.use('/api/v1/apps', rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false }), require('./spaces/api').createSpacesApi({ config, spaces: ctx.spaces, data: ctx.spaceData, sites, takedowns, log }));++ // Space's builder API (plan 2026-10-22 §Z, stage Z1): what the person's own agent (OpenVibe.Actor)++ // works through, and where the person's answer to its publish request lands. It is mounted before++ // /api/v1, which answers everything else.++ app.use('/api/v1/builder/spaces', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./spaces/agent-api').createSpacesBuilderApi({ config, spaces: ctx.spaces, steps: ctx.spaceSteps, log }));++ app.use('/api/v1/agent/approvals', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./spaces/agent-api').createApprovalsApi({ spaces: ctx.spaces, steps: ctx.spaceSteps, log }));app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));{@@@ -29,6 +29,10 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkapp.locals.hosting.outbox.start();app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)++ // The person's own agent (plan 2026-10-22 §Z, stage Z1): the watcher follows the tasks that build spaces++ // (off unless SPACE_BUILDER=agent and OpenVibe.Actor is there).++ const agentWatch = app.locals.hosting.spaceAgentWatch;++ if (agentWatch && agentWatch.enabled) agentWatch.start();// Sharing (openvibe-sdk/share): expire members and transfers, then send queued share events and notifications.const sharingTimer = setInterval(() => { app.locals.hosting.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);if (sharingTimer.unref) sharingTimer.unref();@@@ -49,7 +53,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkgracefulStop({name: 'Space', server,drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,-- stop: [async () => { if (subscriptions) subscriptions.stop(); clearInterval(sharingTimer); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],++ stop: [async () => { if (subscriptions) subscriptions.stop(); clearInterval(sharingTimer); if (agentWatch) agentWatch.stop(); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],close: [async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },async () => {nnew file mode 100644@@@ -0,0 +1,180 @@++'use strict';++/**++ * Space's builder API: what the person's own agent works through (plan 2026-10-22 §Z stage Z1).++ *++ * GET /api/v1/builder/spaces the person's own spaces++ * POST /api/v1/builder/spaces make a new one++ * GET /api/v1/builder/spaces/:id one of them++ * GET /api/v1/builder/spaces/:id/files its files++ * GET /api/v1/builder/spaces/:id/files/:path one file++ * PUT /api/v1/builder/spaces/:id/files write its files (they replace the space's files as a whole)++ * GET /api/v1/builder/spaces/:id/preview where it can be seen now++ * POST /api/v1/builder/spaces/:id/publish ask to publish it (the person answers; nothing goes live until they do)++ *++ * (The address is /api/v1/builder/spaces because /api/v1/spaces was the forum's old API, which lives on++ * OpenVibe.Community now and is still redirected there.)++ *++ * The caller is OpenVibe.Actor, acting for the person it names in X-OV-Subject. Space reads that person and++ * touches nothing else: another person's space is the same 404 as one that does not exist. The capabilities++ * (space.build.read, space.build.create, space.build.write, space.build.publish) are checked on the token; the++ * writes go through the same path as Space's own editor, with the same limits and the same audit.++ *++ * POST /api/v1/agent/approvals/:id/decide the person's answer to the agent's request to publish++ *++ * The answer comes from the Dock: OpenVibe.Actor passes it on with the person's own token, so Space sees the++ * person who owns the space, never a service deciding for them.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { guard } = require('../auth/viewer');++const { ApiError } = require('../http/errors');++++/** The person a call is for: the signed-in person, or the one a first-party service names in X-OV-Subject. */++function personOf(req) {++ const v = req.viewer;++ if (!v) return null;++ if (v.kind === 'user') return v.subject;++ if (v.kind === 'service' && typeof v.subject === 'string' && v.subject) return v.subject;++ return null;++}++++function createSpacesBuilderApi({ config, spaces, steps = null, log = console }) {++ const router = express.Router();++ const json = express.json({ limit: '256kb' });++++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const fail = (req, res, err) => {++ if (err instanceof ApiError || (err && Number.isInteger(err.status) && err.code)) {++ return contracts.http.sendProblem(res, err.status || 400, err.code || 'space.error', { detail: err.message, ctx: req.ov });++ }++ log.error('[Spaces builder]', err && err.stack ? err.stack : err);++ return contracts.http.sendProblem(res, 500, 'internal', { detail: 'Something went wrong on our side', ctx: req.ov });++ };++ const run = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.status(200).set('Cache-Control', 'no-store').json(out); } catch (err) { fail(req, res, err); } };++++ /** The person's own space by id. Another person's space is the same 404 as one that does not exist. */++ async function ownSpace(req) {++ const person = personOf(req);++ if (!person) throw new ApiError(401, 'auth.required', 'this work runs for one person; sign in');++ const space = await spaces.ownedOf(person, req.params.id);++ if (!space) throw new ApiError(404, 'space.not_found', 'no such space');++ return { person, space };++ }++ const out = (space) => ({++ id: space.id,++ slug: space.slug,++ name: space.name,++ description: space.description || '',++ published: Boolean(space.site_id),++ url: space.site_id ? spaces.urlOf(space) : `${config.baseUrl}/spaces/${encodeURIComponent(space.slug)}/edit`,++ updated_at: new Date(Number(space.updated_at)).toISOString(),++ });++++ router.get('/', guard('space.build.read'), run(async (req) => {++ const person = personOf(req);++ if (!person) throw new ApiError(401, 'auth.required', 'sign in to see your spaces');++ const mine = await spaces.mineOf(person);++ return { spaces: mine.map(out) };++ }));++++ router.post('/', guard('space.build.create'), json, run(async (req) => {++ const person = personOf(req);++ if (!person) throw new ApiError(401, 'auth.required', 'sign in to make a space');++ const body = req.body || {};++ const templates = require('./templates');++ const blank = templates.get('blank');++ const space = await spaces.create(spaces.viewerOf(person, { username: '', name: '' }), {++ name: String(body.name || 'New space').slice(0, 40),++ description: String(body.description || '').slice(0, 400),++ capabilities: blank.capabilities,++ files: templates.files('blank'),++ template: null,++ });++ return { space: out(space) };++ }));++++ router.get('/:id', guard('space.build.read'), run(async (req) => ({ space: out((await ownSpace(req)).space) })));++++ router.get('/:id/files', guard('space.build.read'), run(async (req) => {++ const { space } = await ownSpace(req);++ return { files: await spaces.files(space) };++ }));++++ router.get('/:id/files/:path(*)', guard('space.build.read'), run(async (req) => {++ const { space } = await ownSpace(req);++ const path = String(req.params.path || '').replace(/^\.?\//, '');++ const files = await spaces.files(space);++ const one = files.find((f) => f.path === path);++ if (!one) throw new ApiError(404, 'space.file_not_found', 'no such file in this space');++ return { files: [one] };++ }));++++ router.put('/:id/files', guard('space.build.write'), json, run(async (req) => {++ const { person, space } = await ownSpace(req);++ const body = req.body || {};++ const files = await spaces.saveFiles(spaces.viewerOf(person, space), space, body.files);++ if (steps) await steps.working({ space, person });++ return { files };++ }));++++ router.get('/:id/preview', guard('space.build.read'), run(async (req) => {++ const { space } = await ownSpace(req);++ const files = await spaces.files(space);++ return {++ url: space.site_id ? spaces.urlOf(space) : `${config.baseUrl}/spaces/${encodeURIComponent(space.slug)}/edit`,++ files: files.map((f) => f.path),++ published: Boolean(space.site_id),++ };++ }));++++ router.post('/:id/publish', guard('space.build.publish'), json, run(async (req) => {++ const { person, space } = await ownSpace(req);++ // While public hosting is off, nothing can go live (an owner decision): the agent is told now, instead++ // of asking the person to approve something that could not succeed.++ const staff = req.viewer.kind === 'user' && req.viewer.staff;++ if (!config.publicHosting && !staff) {++ throw new ApiError(403, 'space.not_launched', 'Publishing a space at its own address opens with OpenVibe.Space\'s public hosting; until then the space runs in its editor.');++ }++ const ask = await spaces.askPublish(person, space);++ if (steps) await steps.publishAsked({ space, person, request: ask });++ return { state: ask.state, approval_id: ask.id, url: ask.url || null, reason: ask.reason || null };++ }));++++ return router;++}++++/**++ * The person's answer to the agent's request to publish a space. OpenVibe.Actor passes it on with the person's++ * own token (the Dock's "Needs you" card), so Space sees the person who owns the space.++ *++ * Approving publishes the space now; declining leaves it as it is. Nothing goes live without the answer.++ */++function createApprovalsApi({ spaces, steps = null, log = console }) {++ const router = express.Router();++ const json = express.json({ limit: '16kb' });++++ router.post('/:id/decide', guard('space.build.publish'), json, async (req, res) => {++ try {++ const person = personOf(req);++ if (!person) return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'sign in to answer', ctx: req.ov });++ const decision = String((req.body || {}).decision || '').trim().toLowerCase();++ if (decision !== 'approve' && decision !== 'decline') {++ return contracts.http.sendProblem(res, 422, 'space.approval.invalid', { detail: 'decision: approve or decline', ctx: req.ov });++ }++ const out = await spaces.decidePublish(person, req.params.id, decision);++ if (out.error) return contracts.http.sendProblem(res, out.status || 404, 'space.approval.not_found', { detail: out.error, ctx: req.ov });++ const space = out.space || null;++ if (steps && space) {++ await steps.publishAnswered({ space, person, request: { id: req.params.id }, approved: out.state === 'approved', reason: out.reason || null });++ }++ res.status(200).set('Cache-Control', 'no-store').json({ state: out.state, url: out.url || null, reason: out.reason || null });++ } catch (err) {++ log.error('[Spaces approvals]', err && err.stack ? err.stack : err);++ return contracts.http.sendProblem(res, 500, 'internal', { detail: 'Something went wrong on our side', ctx: req.ov });++ }++ });++++ return router;++}++++module.exports = { createSpacesBuilderApi, createApprovalsApi, personOf };nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * The watcher (plan 2026-10-22 §Z stage Z1): Space follows the task of the person's agent that builds a space.++ *++ * Every few seconds it asks OpenVibe.Actor how the task is doing, and it publishes the step into the network's++ * agent namespace (server/spaces/steps.js), so the person sees it in the one timeline behind the Dock. The space's++ * own page asks Actor the same question while it is open, and shows the space's live preview.++ *++ * The watcher is the safety net: when the person closes the page, the task keeps running and the Dock still shows++ * what it is doing.++ */++++/** Actor's task states, and the ones Space keeps (running, waiting, done, failed). */++const { STATE } = require('./agent');++++function createAgentWatcher({ config, spaces, agent, steps = null, log = console }) {++ const enabled = Boolean(agent && agent.enabled) && config.builder.mode === 'agent';++ const intervalMs = config.builder.pollMs;++ let timer = null;++ let busy = false;++++ /** One round: every space whose agent task is still open, asked about its task. */++ async function tick() {++ if (busy) return;++ busy = true;++ try {++ const rows = await spaces.agentRunning();++ for (const space of rows) {++ const person = space.owner_subject;++ const taskId = space.agent_task_id;++ const t = await agent.task({ person, id: taskId });++ if (!t) {++ await spaces.setAgentState(space, 'failed');++ if (steps) await steps.ended({ space, person, taskId, ok: false, reason: 'the task is gone' });++ continue;++ }++ const state = STATE[t.state] || 'running';++ if (state !== space.agent_task_state) {++ await spaces.setAgentState(space, state);++ if (state === 'running' && steps) await steps.working({ space, person, taskId });++ }++ if (state === 'done' || state === 'failed') {++ if (steps) {++ await steps.ended({++ space, person, taskId, ok: state === 'done',++ reason: state === 'done' ? null : (t.error && (t.error.detail || t.error.code)) || t.error || t.state || 'the task did not finish',++ });++ }++ }++ }++ } catch (err) {++ log.warn('[Spaces agent]', (err && err.message) || err);++ } finally {++ busy = false;++ }++ }++++ return {++ enabled,++ tick,++ /** The watcher runs only when the agent is the builder; otherwise the old AI loop stays in charge. */++ start() {++ if (!enabled || timer) return;++ timer = setInterval(() => { tick().catch(() => {}); }, intervalMs);++ if (timer.unref) timer.unref();++ },++ stop() {++ if (timer) clearInterval(timer);++ timer = null;++ },++ };++}++++module.exports = { createAgentWatcher, STATE };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++/**++ * Space → OpenVibe.Actor, for the person's one agent (plan 2026-10-22 §Z stage Z1).++ *++ * Space's builder is not its own AI any more: "Describe it and OpenVibe AI builds it" starts a task of the++ * person's agent (OpenVibe.Actor's engine), and the Dock's one timeline shows the step while Space shows the++ * space's live preview. Space asks Actor for the task and reads it back; the agent works on the space through++ * Space's builder API (the space_* tools).++ *++ * The task is the person's: Space calls Actor with its own service token (audience openvibe.actor) and names the++ * person in X-OV-Subject, so the task is theirs (their allowance, their task list, Space recorded as the service++ * that started it). Actor answers 429/422/5xx in its own words; those pass through to the person.++ */++const { serviceAuth } = require('openvibe-contracts');++++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const SCOPE = 'actor.task.create actor.task.read';++++/**++ * What a task's state means for a space: running, waiting (the task itself asks the person something), done or++ * failed. Actor's own states are the source; a space only keeps these four.++ */++const STATE = {++ queued: 'running',++ running: 'running',++ verifying: 'running',++ needs_you: 'waiting',++ succeeded: 'done',++ failed: 'failed',++ cancelled: 'failed',++};++++/**++ * The instruction Actor receives: what to build, in plain words, with the space it works on. The tools are named++ * so the model does not have to guess them.++ */++function taskText(space, prompt, files) {++ const have = (files || []).map((f) => f.path).join(', ');++ return [++ 'Build a space on OpenVibe.Space for the person who asked for it.',++ `The space is ${space.id} ("${space.name}"). Its files now are: ${have || 'none yet'}.`,++ 'Write the space with space_files_write (index.html first, then the rest): the files you send replace the space\'s files as a whole, so send every file it should have.',++ 'Read what is there with space_files_read, and show where the space lives with space_preview.',++ 'Ask before it goes live with space_publish: the person answers, so nothing is published until they do.',++ 'When the space is done, say in one line what it is and where they can see it.',++ '',++ 'What the person asked for:',++ String(prompt || '').trim(),++ ].join('\n');++}++++function createSpaceAgent({ config, fetchImpl = globalThis.fetch, log = console }) {++ const base = String((config.builder && config.builder.actorUrl) || '').replace(/\/+$/, '');++ const clientSecret = config.oauth && config.oauth.clientSecret;++ const enabled = Boolean(base && clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: (config.oauth && config.oauth.clientId) || 'space',++ clientSecret,++ audience: 'openvibe.actor',++ scope: SCOPE,++ fetchImpl,++ }) : null;++ const timeoutMs = (config.builder && config.builder.timeoutMs) || 15_000;++++ /** One call to Actor for one person. */++ async function call(method, path, person, body, extraHeaders) {++ const headers = { Accept: 'application/json', 'X-OV-Subject': person, ...(await tokens.authHeaders()), ...(extraHeaders || {}) };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ log.warn('[Spaces agent] Actor did not answer:', (err && err.message) || err);++ return { status: 502, data: { code: 'actor.unavailable', detail: 'OpenVibe.Actor did not answer. Try again in a moment.' } };++ }++ const text = await res.text().catch(() => '');++ let data = null;++ try { data = text ? JSON.parse(text) : null; } catch { data = null; }++ return { status: res.status, data: data || {} };++ }++++ /**++ * Start a task for the person. Answers { id, state } or { error: { code, message } } when Actor refused++ * (its own words: an allowance that is used up, a budget, a task that is too long).++ */++ async function createTask({ person, task, idempotencyKey }) {++ if (!enabled) return { error: { code: 'actor.unavailable', message: 'Your agent is not available right now. Start from a template instead.' } };++ const key = String(idempotencyKey || '').slice(0, 128) || `space:${person}:${Date.now()}`;++ const out = await call('POST', '/api/v1/tasks', person, { task, idempotency_key: key }, { 'Idempotency-Key': key });++ if (out.status >= 400) {++ const d = out.data || {};++ return { error: { code: d.code || 'actor.refused', message: String(d.detail || d.title || 'Your agent could not start that task.').slice(0, 300) } };++ }++ const id = out.data && out.data.id;++ if (!id || !TASK_ID.test(String(id))) return { error: { code: 'actor.invalid_response', message: 'Your agent did not answer with a task.' } };++ return { id, state: out.data.state || 'queued' };++ }++++ /** The task as it is now: { id, state, title, result } or null when Actor does not know it. */++ async function task({ person, id }) {++ if (!enabled) return null;++ if (!TASK_ID.test(String(id || ''))) return null;++ const out = await call('GET', `/api/v1/tasks/${encodeURIComponent(id)}`, person);++ if (out.status >= 400) return null;++ const d = out.data || {};++ return {++ id: d.id || id,++ state: d.state || 'queued',++ title: typeof d.task === 'string' ? d.task.slice(0, 200) : '',++ result: d.result && typeof d.result === 'object' ? d.result : null,++ error: d.error_code || d.error || null,++ };++ }++++ return { enabled, createTask, task, taskText, stateOf: (s) => STATE[s] || null };++}++++module.exports = { createSpaceAgent, taskText, TASK_ID, SCOPE, STATE };@@@ -145,6 +145,11 @@ function createSpacesPages(ctx) {}}];const buildsLeft = async (req) => (signedIn(req) ? Math.max(0, limits.aiBuildsPerDay - await spaces.used(`build:${req.viewer.subject}`)) : 0);++ /**++ * The agent is the builder (plan 2026-10-22 §Z, stage Z1): the flag says so, and OpenVibe.Actor is there to++ * answer. Guests never get an agent (they have no account, and the agent works for the person).++ */++ const agentOn = () => Boolean(config.builder && config.builder.mode === 'agent' && ctx.spaceAgent && ctx.spaceAgent.enabled);const settingsOut = (s) => ({ id: s.id, slug: s.slug, name: s.name, description: s.description, instructions: s.instructions, capabilities: spaces.capsOf(s.capabilities), visibility: s.visibility, published: Boolean(s.site_id), url: spaces.urlOf(s), building: Boolean(s.building_run_id) });// ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────@@@ -206,12 +211,32 @@ function createSpacesPages(ctx) {}const again = (error) => html(res, 422, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: 0, error, prompt }));if (prompt.length < 8) return again('Describe your space in a sentence or two (what it does, who uses it, how it should look).');-- if (!ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');-- // The abuse screen: what is asked of the AI must pass it (a guest's prompt passes the same checks).++ // The builder: the person's own agent (plan 2026-10-22 §Z, stage Z1) or Space's old AI loop, while the++ // flag says which one it is. The old path is untouched and still works.++ const withAgent = agentOn();++ if (!withAgent && !ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');++ // The abuse screen: what is asked of the builder must pass it (a guest's prompt passes the same checks).contentCheck.refuse(contentCheck.promptReason(prompt));if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) return again(`You have used today's ${limits.aiBuildsPerDay} AI builds. Start from a template, or try again tomorrow.`);const t = templates.get('blank');const space = await spaces.create(req.viewer, { name: 'New space', description: '', capabilities: t.capabilities, files: templates.files('blank'), template: null });++ if (withAgent) {++ // The space is a task of the person's own agent: it writes the files, and the Dock shows the step.++ const person = spaces.personOf(req.viewer);++ const task = await ctx.spaceAgent.createTask({++ person: person.subject,++ task: ctx.spaceAgent.taskText(space, prompt, await spaces.files(space)),++ idempotencyKey: `space:${space.id}`,++ });++ if (task.error) {++ // Nothing was built: the space goes away with the empty promise.++ await spaces.remove(req.viewer, space).catch(() => {});++ return again(task.error.message);++ }++ await spaces.setAgentTask(space, task.id, 'running');++ await ctx.spaceSteps.started({ space, person: person.subject, taskId: task.id });++ return res.redirect(303, `/spaces/${space.slug}/edit`);++ }const runId = await ai.build(spaces.personOf(req.viewer), { prompt, spaceId: space.id });await spaces.startBuild(space, runId, prompt);res.redirect(303, `/spaces/${space.slug}/edit`);@@@ -351,6 +376,21 @@ function createSpacesPages(ctx) {if (!room.ok) throw new ApiError(429, 'quota', room.reason === 'ip'? `you have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds; try again tomorrow`: `this draft has had its ${guestLimits.aiEditsPerDraft} AI changes; try again tomorrow`);++ } else if (agentOn()) {++ // The agent changes the space, as a task of the person's own agent (plan 2026-10-22 §Z stage Z1).++ const person = spaces.personOf(req.viewer);++ if (!(await spaces.spend(`build:${person.subject}`, limits.aiBuildsPerDay))) {++ throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);++ }++ const task = await ctx.spaceAgent.createTask({++ person: person.subject,++ task: ctx.spaceAgent.taskText(space, prompt, await spaces.files(space)),++ idempotencyKey: `space:${space.id}:${ctx.store.now()}`,++ });++ if (task.error) throw new ApiError(502, 'actor.unavailable', task.error.message);++ await spaces.setAgentTask(space, task.id, 'running');++ await ctx.spaceSteps.started({ space, person: person.subject, taskId: task.id });++ return { building: true, task: task.id };} else if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) {throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);}@@@ -384,6 +424,25 @@ function createSpacesPages(ctx) {await spaces.failBuild(space, runId);return { status: 'failed', message: r.error === 'quota.exceeded' ? 'Your OpenVibe AI allowance for today is used up.' : 'The AI could not build that. Try again, or describe it differently.' };}));++ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z, stage Z1): the editor asks++ // it how it is doing, so the page can show the space as the agent writes it.++ router.get('/spaces/:slug/agent', ...api(async (req, res, who) => {++ const space = who.space;++ // A guest draft has no agent (there is no person to run one).++ if (who.guest || !space.agent_task_id) return { status: 'idle' };++ const person = spaces.personOf(req.viewer);++ const t = await ctx.spaceAgent.task({ person: person.subject, id: space.agent_task_id });++ const status = t ? (ctx.spaceAgent.stateOf(t.state) || 'running') : (space.agent_task_state || 'running');++ const fresh = await spaces.get(space.id);++ const publish = space.agent_publish_id ? await spaces.publishRequest(person.subject, space.agent_publish_id) : null;++ return {++ status,++ task: space.agent_task_id,++ files: await spaces.files(fresh),++ publish: publish ? { id: publish.id, state: publish.state } : null,++ message: t && t.result && typeof t.result.answer === 'string' ? t.result.answer.slice(0, 300) : '',++ };++ }));router.post('/spaces/:slug/publish', ...api(async (req, res, who) => {// A guest draft is not published: it has no owner to publish under, and it is static.if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft cannot be published; sign in and keep it first');@@@ -186,6 +186,9 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem// owned space by its slug. A guest carries its secret with every call.base: `/spaces/${guest ? space.id : space.slug}`,guest: guest ? { id: space.id, key: guest.key } : null,++ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z, stage Z1): the editor++ // opens the Dock at it and follows it until it ends.++ agent: space.agent_task_id ? { task: space.agent_task_id, state: space.agent_task_state || 'running' } : null,};// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.const claim = guest ? (guest.signedIn@@@ -221,7 +224,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem<details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details></section></div>-- <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>++ <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2 id="ed-building-title">OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div><dialog class="sp-ed-dialog" id="ed-dialog"><form method="dialog" id="ed-settings-form"><h2>Settings</h2>@@@ -75,6 +75,17 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outbcounter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),++ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z stage Z1).++ setAgentTask: db.prepare('UPDATE space_apps SET agent_task_id = ?, agent_task_state = ?, updated_at = ? WHERE id = ?'),++ setAgentState: db.prepare('UPDATE space_apps SET agent_task_state = ?, updated_at = ? WHERE id = ?'),++ setAgentStep: db.prepare('UPDATE space_apps SET agent_step_id = ?, updated_at = ? WHERE id = ?'),++ setAgentPublish: db.prepare('UPDATE space_apps SET agent_publish_id = ?, updated_at = ? WHERE id = ?'),++ agentTasks: db.prepare("SELECT * FROM space_apps WHERE agent_task_id IS NOT NULL AND agent_task_state IN ('running','waiting') AND status = 'active'"),++ // A publish the agent asked for: the person answers it (migrations/0011_space_agent.sql).++ addPublish: db.prepare(`INSERT INTO space_publish_requests (id, space_id, owner_subject, state, created_at) VALUES (?, ?, ?, 'pending', ?)`),++ publishById: db.prepare('SELECT * FROM space_publish_requests WHERE id = ?'),++ publishPending: db.prepare("SELECT * FROM space_publish_requests WHERE space_id = ? AND state = 'pending' ORDER BY created_at DESC LIMIT 1"),++ decidePublish: db.prepare("UPDATE space_publish_requests SET state = ?, decided_at = ? WHERE id = ? AND state = 'pending'"),// Guest drafts (openvibe.website, plan 5.9): no owner yet, a secret of their own, and a time they live until.insertGuest: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,template, forked_from, visibility, status, created_at, updated_at, guest_hash, guest_ip, guest_expires_at)@@@ -347,6 +358,96 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb}async function used(scope) { const cur = await q.counterGet.get(scope, today()); return cur ? cur.n : 0; }++ // ── The person's one agent (plan 2026-10-22 §Z stage Z1) ────────────────────────────────────────────++ // A space may carry the task of the person's agent that builds it. The agent works through the builder API++ // (server/spaces/agent-api.js) as the person, and the task is theirs: Space asked Actor for it with the++ // person in X-OV-Subject.++++ /** The person's own space by id. Another person's space is the same 404 as one that does not exist. */++ async function ownedOf(person, id) {++ const space = isSpaceId(id) ? (await q.byId.get(id)) || null : null;++ if (!space || space.owner_subject !== person) return null;++ return space;++ }++++ /** The person's own spaces, newest first. */++ async function mineOf(person) {++ if (!person) return [];++ return await q.mine.all(person);++ }++++ /** The viewer of a person: what Space's own write paths expect (the owner of a space). */++ function viewerOf(person, space) {++ return {++ kind: 'user',++ subject: person,++ staff: false,++ user: { username: (space && space.owner_username) || '', name: (space && space.owner_name) || '' },++ };++ }++++ /** The task of the person's agent that builds this space, and what it is doing. */++ async function setAgentTask(space, taskId, state) {++ await q.setAgentTask.run(String(taskId || ''), String(state || 'queued'), store.now(), space.id);++ return await get(space.id);++ }++ async function setAgentState(space, state) {++ await q.setAgentState.run(String(state || ''), store.now(), space.id);++ return await get(space.id);++ }++ async function setAgentStep(space, stepId) {++ await q.setAgentStep.run(String(stepId || ''), store.now(), space.id);++ return await get(space.id);++ }++ /** The spaces whose agent task is still running: the watcher follows them. */++ async function agentRunning() { return await q.agentTasks.all(); }++++ /**++ * The agent asks to publish a space. Nothing goes live: the request waits for the person, who answers it in++ * the Dock ("Needs you") or on the space's page. A request that is already waiting is answered as it is.++ */++ async function askPublish(person, space) {++ const pending = await q.publishPending.get(space.id);++ if (pending) return { id: pending.id, state: 'pending', url: null, reason: null };++ const id = `apr_${ids.ulid(store.now())}`;++ await q.addPublish.run(id, space.id, person, store.now());++ await q.setAgentPublish.run(id, store.now(), space.id);++ return { id, state: 'pending', url: null, reason: null };++ }++++ /** The publish request the person is answering, when it is theirs. */++ async function publishRequest(person, id) {++ const row = await q.publishById.get(String(id || ''));++ if (!row || row.owner_subject !== person) return null;++ return row;++ }++++ /**++ * The person's answer to a publish request: approved publishes the space now, declined leaves it as it is.++ * Answering twice is the answer that stands.++ */++ async function decidePublish(person, id, decision) {++ const row = await q.publishById.get(String(id || ''));++ if (!row || row.owner_subject !== person) return { error: 'No such request of yours.', status: 404 };++ if (row.state !== 'pending') return { state: row.state, space: await get(row.space_id) };++ const now = store.now();++ if (decision === 'decline') {++ await q.decidePublish.run('declined', now, row.id);++ return { state: 'declined', space: await get(row.space_id) };++ }++ const space = await get(row.space_id);++ try {++ const out = await publish(viewerOf(person, space), space);++ await q.decidePublish.run('approved', now, row.id);++ return { state: 'approved', space: out.space, url: out.url };++ } catch (err) {++ // The person said yes, but the space could not go live (public hosting is not open yet, most of all).++ // The request remembers the answer and why it did not work.++ await q.decidePublish.run('failed', now, row.id);++ return { state: 'failed', space, reason: (err && err.message) || 'the space could not be published' };++ }++ }++// ── Publishing: the hosting engine serves a published space ──const canPublish = (viewer) => Boolean(config.publicHosting || (viewer && viewer.staff));@@@ -418,6 +519,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outbCAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,personOf, capsOf, checkFiles, DAY,++ // The person's one agent (plan 2026-10-22 §Z stage Z1)++ ownedOf, mineOf, viewerOf, setAgentTask, setAgentState, setAgentStep, agentRunning,++ askPublish, publishRequest, decidePublish,// Guest drafts (openvibe.website, plan 5.9)isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,};nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++/**++ * Space → the agent's one timeline (plan 2026-10-22 §Z stage Z1).++ *++ * Every step of the person's agent that builds a space is published into the network's agent namespace++ * (agent.step.started | updated | ended, contracts common.agent-step@1), with the person as its subject: OpenVibe.Actor++ * keeps the one timeline behind the Dock, and the person's Dock shows the step while Space shows the space's live++ * preview. A step carries a title, an outcome and a link: never a prompt, a diff or a page.++ *++ * The publish the agent asks for is a step of its own, waiting for the person ("Needs you" in the Dock): the person++ * answers it there, and the answer lands in Space.++ */++const contracts = require('openvibe-contracts');++++const MODE = 'act'; // the agent acts on the person's space++const PRODUCT = 'space';++++function createSpaceSteps({ outbox, config = {}, log = console }) {++ const enabled = Boolean(outbox && typeof outbox.emit === 'function');++++ /** One step into the timeline. Nothing is published when the payload does not fit the contract. */++ async function emit(type, { space, person, stepId, status, title, link, reason, approval, endedAt, startedAt }) {++ if (!enabled) return null;++ const payload = {++ step_id: String(stepId || '').slice(0, 80),++ mode: MODE,++ product: PRODUCT,++ ref: String(stepId || '').slice(0, 120),++ title: String(title || 'A step of the agent').slice(0, 160),++ status,++ started_at: startedAt || new Date().toISOString(),++ ended_at: endedAt || null,++ link: link || null,++ reason: reason ? String(reason).slice(0, 240) : null,++ approval: approval || null,++ };++ const v = contracts.validate('common.agent-step@1', payload);++ if (!v.valid) {++ log.warn('[Spaces steps] the step does not fit the contract:', (v.errors || []).map((e) => e.message).join('; '));++ return null;++ }++ try {++ await outbox.emit({++ event_type: `agent.step.${type}`,++ actor: { type: 'user', id: person },++ subject: { type: 'user', id: person },++ visibility: 'internal',++ payload,++ });++ outbox.kick();++ } catch (err) {++ log.warn('[Spaces steps] the step could not be published:', (err && err.message) || err);++ }++ return payload;++ }++++ const editorOf = (space) => `${config.baseUrl || 'https://openvibe.space'}/spaces/${encodeURIComponent(space.slug)}/edit`;++++ return {++ enabled,++ /** The agent started building this space. */++ async started({ space, person, taskId, startedAt }) {++ return await emit('started', {++ space, person, stepId: taskId, status: 'running',++ title: `Building ${space.name}`,++ link: editorOf(space), startedAt,++ });++ },++ /** The agent wrote the space's files: the same step, brought forward. */++ async working({ space, person, taskId }) {++ return await emit('updated', {++ space, person, stepId: taskId, status: 'running',++ title: `Building ${space.name}`,++ link: editorOf(space),++ });++ },++ /** The task ended. */++ async ended({ space, person, taskId, ok, reason, endedAt }) {++ return await emit('ended', {++ space, person, stepId: taskId, status: ok ? 'done' : 'failed',++ title: ok ? `Built ${space.name}` : `Could not build ${space.name}`,++ link: editorOf(space), reason: ok ? null : reason, endedAt,++ });++ },++ /** The agent asks to publish: a step of its own that waits for the person. */++ async publishAsked({ space, person, request }) {++ return await emit('started', {++ space, person, stepId: request.id, status: 'needs_you',++ title: `Publish ${space.name}?`,++ link: editorOf(space),++ reason: 'Your agent wants to publish this space. Nothing goes live until you say yes.',++ approval: { product: PRODUCT, id: request.id, kind: 'space.publish' },++ });++ },++ /** The person answered the publish request. */++ async publishAnswered({ space, person, request, approved, reason }) {++ return await emit('ended', {++ space, person, stepId: request.id, status: approved ? 'done' : 'failed',++ title: approved ? `Published ${space.name}` : `Did not publish ${space.name}`,++ link: editorOf(space), reason: approved ? null : reason, endedAt: new Date().toISOString(),++ });++ },++ };++}++++module.exports = { createSpaceSteps, MODE, PRODUCT };@@@ -44,8 +44,16 @@ const { EVENT_TYPES } = require('../server/events/outbox');await check('the proposed service manifest declares every event the service emits', async () => {assert.strictEqual(manifest.id, 'space');const pinned = new Set(contracts.catalog.map((c) => c.id));++ // The agent.* namespace belongs to the network (like provider.* and share.*): a first-party mode publishes++ // its own steps into it, and OpenVibe.Actor keeps the one timeline behind the Dock. One producer per event++ // type is a manifest rule, so Space's manifest does not list them (OpenVibe.Run does the same).++ const SHARED = ['agent.', 'provider.', 'share.'];for (const e of EVENT_TYPES) {if (manifest.eventsProduced.includes(e)) continue;++ if (SHARED.some((p) => e.startsWith(p))) {++ assert.ok(pinned.has(e), `${e} is a shared-namespace type the pinned catalogue knows`);++ continue;++ }// An event the pinned openvibe-contracts does not know yet: it waits for its contracts PR and for// the pin that follows it (space.app.claimed, plan 5.9). An event neither knows is a mistake.assert.ok(!pinned.has(e), `${e} is in neither the service manifest nor the pinned catalogue`);nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++/**++ * The builder as the person's own agent (plan 2026-10-22 §Z, stage Z1).++ *++ * "Describe it and OpenVibe AI builds it" starts a task of the person's one agent (OpenVibe.Actor): the space++ * is the task's subject, the agent works on it through Space's builder API, and the page shows the space as the++ * agent writes it. Publishing waits for the person: the agent asks, the person answers.++ *++ * The tests here are Space's side of it: the task is asked for with the person named, the builder API reaches++ * only that person's own spaces, the page carries the task and follows it, and nothing goes live before the++ * person says so.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++const { csrfToken } = require('../server/auth/forms');++++const ORIGIN = 'https://openvibe.space';++const TASK = 'tsk_01M4W5JBFEG3QG9ER8W1A2K0RX';++const BUILD_CAPS = ['space.build.read', 'space.build.create', 'space.build.write', 'space.build.publish'];++++(async () => {++ const actorCalls = [];++ let taskState = 'running';++ // The fake OpenVibe.Actor: it takes the task Space asks for and answers how it is doing. Everything else++ // (the token for the service) goes to the network the test runs.++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(url);++ if (u.host !== 'actor.test') return await fetch(url, init);++ const headers = init.headers || {};++ actorCalls.push({++ method: init.method || 'GET', path: u.pathname, subject: headers['X-OV-Subject'],++ auth: headers.Authorization || null, body: init.body ? JSON.parse(init.body) : null,++ });++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++ if (init.method === 'POST') return json(201, { id: TASK, state: 'queued' });++ return json(200, {++ id: TASK, state: taskState, task: 'build it',++ result: taskState === 'succeeded' ? { answer: 'A little quiz about the nineties.' } : null,++ });++ };++++ const t = await boot({ env: { SPACE_BUILDER: 'agent', OV_ACTOR_INTERNAL_URL: 'http://actor.test' }, fetchImpl });++ const alice = t.user('alice'), bob = t.user('bob');++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });++ const form = (u, p, fields) => t.api('POST', p, { session: u, form: { csrf: csrf(u), ...fields }, headers: { origin: ORIGIN } });++ const agent = (method, p, person, json) => t.api(method, p, { as: t.network.serviceToken('actor', BUILD_CAPS), json, headers: { 'x-ov-subject': person.subject } });++++ let slug = null;++ let spaceId = null;++++ await check('the Describe it box starts a task of the person\'s own agent', async () => {++ const r = await form(alice, '/spaces/new', { prompt: 'A trivia quiz about 90s cartoons with a leaderboard' });++ assert.strictEqual(r.status, 303, r.text);++ slug = String(r.headers.location || '').split('/').slice(-2)[0];++ assert.ok(slug, 'the space was made');++ const call = actorCalls.find((c) => c.method === 'POST' && c.path === '/api/v1/tasks');++ assert.ok(call, 'the agent was asked for a task');++ assert.strictEqual(call.subject, alice.subject, 'the task is the person\'s');++ assert.ok(/X-OV-Subject|subject/.test(JSON.stringify(call)), 'the person is named for the agent');++ assert.match(call.body.task, /space/i, 'the task says what to build');++ assert.match(call.body.task, /spc_[0-9A-HJKMNP-TV-Z]{26}/, 'the task names the space it works on');++ });++++ await check('the editor carries the task, so the Dock opens at it', async () => {++ const page = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes(TASK), 'the page carries the task id');++ const boot = JSON.parse(page.text.match(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/)[1].replace(/\\u003c/g, '<'));++ assert.strictEqual(boot.agent.task, TASK);++ assert.strictEqual(boot.agent.state, 'running');++ spaceId = boot.space.id;++ // The page opens the Dock at that task: the shared navbar's API (the Dock's Agent panel marks the step).++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'space-editor.js'), 'utf8');++ assert.ok(/OpenVibeNavbar\.openDock\('agent', \{ task: agent\.task \}\)/.test(js), 'the editor opens the Dock at the task');++ });++++ await check('the agent works on the space through the builder API, and the page shows it', async () => {++ assert.ok(spaceId, 'the space has an id');++ const write = await agent('PUT', `/api/v1/builder/spaces/${spaceId}/files`, alice, { files: [{ path: 'index.html', content: '<h1>90s cartoons</h1>' }] });++ assert.strictEqual(write.status, 200, write.text);++ assert.strictEqual(write.json().files[0].path, 'index.html');++ // The page follows the task and shows the space as the agent wrote it.++ const follow = await t.api('GET', `/spaces/${slug}/agent`, { session: alice, headers: { origin: ORIGIN, 'x-csrf-token': csrf(alice) } });++ assert.strictEqual(follow.status, 200, follow.text);++ assert.strictEqual(follow.json().task, TASK);++ assert.strictEqual(follow.json().status, 'running');++ assert.strictEqual(follow.json().files[0].content, '<h1>90s cartoons</h1>', 'the page shows what the agent wrote');++ });++++ await check('the editor\'s own change box starts a task of the agent too', async () => {++ const r = await t.api('POST', `/spaces/${slug}/ai`, { session: alice, json: { prompt: 'make the buttons bigger' }, headers: { origin: ORIGIN, 'x-csrf-token': csrf(alice) } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.json().task, 'the answer names the task of the agent');++ assert.strictEqual(r.json().building, true);++ const call = actorCalls.filter((c) => c.method === 'POST' && c.path === '/api/v1/tasks').pop();++ assert.strictEqual(call.subject, alice.subject);++ assert.match(call.body.task, /make the buttons bigger/, 'the agent is told what to change');++ });++++ await check('another person\'s space is refused: the same 404 as one that does not exist', async () => {++ const r = await agent('GET', `/api/v1/builder/spaces/${spaceId}`, bob, null);++ assert.strictEqual(r.status, 404, r.text);++ const files = await agent('GET', `/api/v1/builder/spaces/${spaceId}/files`, bob, null);++ assert.strictEqual(files.status, 404, files.text);++ });++++ await check('publishing waits for the person: nothing goes live before they answer', async () => {++ const ask = await agent('POST', `/api/v1/builder/spaces/${spaceId}/publish`, alice, {});++ assert.strictEqual(ask.status, 200, ask.text);++ assert.strictEqual(ask.json().state, 'pending');++ const approval = ask.json().approval_id;++ assert.ok(approval, 'the request the person answers has an id');++ // Still a draft while it waits.++ const page = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });++ assert.ok(!/"published":true/.test(page.text), 'nothing is live yet');++++ const answer = await t.api('POST', `/api/v1/agent/approvals/${approval}/decide`, { as: alice, json: { decision: 'approve' }, headers: { origin: ORIGIN } });++ assert.strictEqual(answer.status, 200, answer.text);++ assert.strictEqual(answer.json().state, 'approved');++ const after = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });++ assert.ok(/"published":true/.test(after.text), 'the space is live after the answer');++ });++++ await check('the step the agent takes reaches the network\'s agent namespace', async () => {++ const steps = await t.events('agent.step.started');++ assert.ok(steps.length, 'the step was published');++ const step = steps[0];++ assert.strictEqual(step.source, 'space');++ assert.strictEqual(step.subject.id, alice.subject, 'the step is the person\'s');++ assert.strictEqual(step.payload.mode, 'act');++ assert.strictEqual(step.payload.product, 'space');++ assert.strictEqual(step.payload.step_id, TASK);++ });++++ await check('while public hosting is off, the agent is told at once that a space cannot go live', async () => {++ const t2 = await boot({ env: { SPACE_BUILDER: 'agent', OV_ACTOR_INTERNAL_URL: 'http://actor.test', SPACE_PUBLIC_HOSTING: 'off' }, fetchImpl });++ try {++ const carol = t2.user('carol');++ const csrf2 = (u) => csrfToken(t2.config, { subject: u.subject });++ const made = await t2.api('POST', '/spaces/new', { session: carol, form: { csrf: csrf2(carol), prompt: 'A small diary for my notes' }, headers: { origin: ORIGIN } });++ assert.strictEqual(made.status, 303, made.text);++ const slug2 = String(made.headers.location || '').split('/').slice(-2)[0];++ const page = await t2.api('GET', `/spaces/${slug2}/edit`, { session: carol });++ const id2 = JSON.parse(page.text.match(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/)[1].replace(/\\u003c/g, '<')).space.id;++ const ask = await t2.api('POST', `/api/v1/builder/spaces/${id2}/publish`, {++ as: t2.network.serviceToken('actor', BUILD_CAPS), json: {}, headers: { 'x-ov-subject': carol.subject },++ });++ assert.strictEqual(ask.status, 403, ask.text);++ assert.match(ask.text, /public hosting/, 'the reason says why');++ } finally {++ await t2.close();++ }++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -43,6 +43,13 @@ DATABASE_DIRECT_URL=
@@ -43,6 +43,13 @@ DATABASE_DIRECT_URL=
SPACE_PUBLIC_HOSTING=off
SPACE_PUBLIC_HOSTING=off
# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
#SPACE_AI=on
#SPACE_AI=on
+# Who builds a space when a person describes one (plan 2026-10-22 §Z, stage Z1).
+# legacy Space's own AI loop (the way it was)
+# agent a task of the person's own agent (OpenVibe.Actor), with the space_* tools
+# The old path stays until the new one is proven; nothing is deleted.
+#SPACE_BUILDER=legacy
+# OpenVibe.Actor, the person's one agent (the task Space starts, and the tools it works with).
+#OV_ACTOR_INTERNAL_URL=http://127.0.0.1:4950
# OpenVibe.Events → Space (account export and deletion, server/account-data.js): signs deliveries to /internal/events (32+ characters).
# OpenVibe.Events → Space (account export and deletion, server/account-data.js): signs deliveries to /internal/events (32+ characters).
#SPACE_EVENTS_SECRET=
#SPACE_EVENTS_SECRET=
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## Unreleased
+- **The builder is the person's own agent** (plan 2026-10-22 §Z, stage Z1): "Describe it and OpenVibe AI builds it" starts a task of the person's one agent (OpenVibe.Actor) instead of Space's own AI loop. The space is the task's subject; the agent writes its files through Space's builder API (`/api/v1/builder/spaces`, the capabilities `space.build.read|create|write|publish`), and the editor opens the Dock at that task and shows the space as the agent writes it. The step reaches the network's agent namespace (`agent.step.started|updated|ended`, product `space`), so the Dock's one timeline shows it. Publishing is never done on its own: the agent asks, the person's answer lands in the Dock ("Needs you") and nothing goes live until they say yes (`POST /api/v1/agent/approvals/:id/decide`). The old builder stays behind `SPACE_BUILDER=legacy` until the new one is proven; nothing is deleted. `test/spaces-agent.test.js`.
- **The forum's links follow Community's move to `/forum`** (design 2026-11-07): Space's forum redirect now
- **The forum's links follow Community's move to `/forum`** (design 2026-11-07): Space's forum redirect now
maps `/s` and `/s/*` straight to `/forum` and `/forum/<space>/<thread>` (the `/t/` segment is gone) instead
maps `/s` and `/s/*` straight to `/forum` and `/forum/<space>/<thread>` (the `/t/` segment is gone) instead
of passing the old path on, so a bookmark lands in one hop. The home page, both navbars and `llms.txt` link
of passing the old path on, so a bookmark lands in one hop. The home page, both navbars and `llms.txt` link
@@ -6,15 +6,19 @@
@@ -6,15 +6,19 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": 4940,
"port": 4940,
"domain": "openvibe.space",
"domain": "openvibe.space",
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.324.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
- "shared": "openvibe-shared v4.133.0",
+ "shared": "openvibe-shared v4.134.0",
"capabilities": [
"capabilities": [
"space.site.manage",
"space.site.manage",
"space.site.config",
"space.site.config",
"space.deploy.create",
"space.deploy.create",
"space.domain.manage",
"space.domain.manage",
- "space.resource.read"
+ "space.resource.read",
+ "space.build.read",
+ "space.build.create",
+ "space.build.write",
+ "space.build.publish"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"deployed": true,
"deployed": true,
@@ -37,5 +41,10 @@
@@ -37,5 +41,10 @@
"No app or agent holds a space.* grant on OpenVibe.Network yet; staff use the dashboard"
"No app or agent holds a space.* grant on OpenVibe.Network yet; staff use the dashboard"
],
],
"hostingDeployed": false,
"hostingDeployed": false,
- "updated": "2026-11-07"
+ "updated": "2026-11-07",
+ "packages": [
+ "openvibe-contracts v0.324.0",
+ "openvibe-sdk v0.94.0",
+ "openvibe-shared v4.134.0"
+ ]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: expand
+-- Space's builder as a task of the person's one agent (plan 2026-10-22 section Z, stage Z1).
+--
+-- A space may carry the task of the person's agent that builds it: the task's id (Actor's tsk_…), what the task is
+-- doing (running, done, failed), the step it shows in the Dock's one timeline, and the publish request waiting for
+-- the person's answer. Times are epoch milliseconds like the other space tables.
+ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_task_id text COLLATE "C";
+ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_task_state text COLLATE "C";
+ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_step_id text COLLATE "C";
+ALTER TABLE space_apps ADD COLUMN IF NOT EXISTS agent_publish_id text COLLATE "C";
+CREATE INDEX IF NOT EXISTS space_apps_agent_task ON space_apps (agent_task_id) WHERE agent_task_id IS NOT NULL;
+
+-- A publish the agent asked for. The person answers it in the Dock ("Needs you") or on the space's page; nothing
+-- goes live until they do. The row is kept after the answer, so the space's history says what was published and when.
+CREATE TABLE IF NOT EXISTS space_publish_requests (
+ id text COLLATE "C" PRIMARY KEY, -- apr_<ULID>
+ space_id text COLLATE "C" NOT NULL REFERENCES space_apps(id) ON DELETE CASCADE,
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…
+ state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','approved','declined','failed')),
+ created_at bigint NOT NULL,
+ decided_at bigint
+);
+CREATE INDEX IF NOT EXISTS space_publish_requests_pending ON space_publish_requests (owner_subject, state) WHERE state = 'pending';
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
"quickjs-emscripten": "0.32.0"
},
},
@@ -1122,9 +1122,9 @@
@@ -1122,9 +1122,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.324.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
+ "integrity": "sha512-fSeabmHcX74SxVKEfGXMgWhEDugYQh5KWB+5Vj86CTvr2q50e/7AfHBDtVVw2WYQ06wiH1wN0hctDYoc2DQi9Q==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1166,9 +1166,9 @@
@@ -1166,9 +1166,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.133.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
- "integrity": "sha512-ry+yvht0O+fHhMQr2LOaQKRYJo38d2cgsI3UtRmHge91qEQ/gL0wtcHawME1/G7V0NrlpdGzJMueKYd8UKrhjw==",
+ "version": "4.134.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0",
+ "integrity": "sha512-uCqBvpgWXrcZo1EEBD2T6QC+CoNd1BV81MLnj156fBLJk+fWmCJI6QPY17iQGtPdFMg0b5k7wVfYrg6n/o8dhw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -27,9 +27,9 @@
@@ -27,9 +27,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.324.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.134.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"quickjs-emscripten": "0.32.0"
"quickjs-emscripten": "0.32.0"
},
},
@@ -147,8 +147,7 @@
@@ -147,8 +147,7 @@
// ── AI changes ──
// ── AI changes ──
var overlay = $('ed-building');
var overlay = $('ed-building');
function building(on, text) { overlay.hidden = !on; $('ed-building-text').textContent = text ? '“' + text + '”' : ''; }
function building(on, text) { overlay.hidden = !on; $('ed-building-text').textContent = text ? '“' + text + '”' : ''; }
- function poll() {
- call('GET', '/ai').then(function (j) {
+ function poll() { call('GET', '/ai').then(function (j) {
if (j.status === 'running') return setTimeout(poll, 3000);
if (j.status === 'running') return setTimeout(poll, 3000);
building(false);
building(false);
if (j.status === 'done') {
if (j.status === 'done') {
@@ -165,12 +164,60 @@
@@ -165,12 +164,60 @@
var input = $('ed-ai'), prompt = input.value.trim();
var input = $('ed-ai'), prompt = input.value.trim();
if (!prompt) return;
if (!prompt) return;
var go = function () {
var go = function () {
- return call('POST', '/ai', { prompt: prompt }).then(function () { input.value = ''; building(true, prompt); setTimeout(poll, 3000); }, function (err) { status(err.message, 'bad'); });
+ return call('POST', '/ai', { prompt: prompt }).then(function (j) {
+ input.value = '';
+ building(true, prompt);
+ // The answer names a task of the person's own agent (plan 2026-10-22 §Z stage Z1): follow it there.
+ if (j && j.task) { agent = { task: j.task, state: 'running' }; var title = $('ed-building-title'); if (title) title.textContent = 'Your agent is building this…'; openDock(); setTimeout(agentPoll, 1500); return; }
+ setTimeout(poll, 3000);
+ }, function (err) { status(err.message, 'bad'); });
};
};
if (dirty) save().then(go, function () {}); else go();
if (dirty) save().then(go, function () {}); else go();
});
});
if (space.building) { building(true, space.building_prompt); setTimeout(poll, 1500); }
if (space.building) { building(true, space.building_prompt); setTimeout(poll, 1500); }
+ // ── The person's own agent (plan 2026-10-22 §Z, stage Z1) ──
+ // The space is a task of the agent: the Dock opens at it, the page follows it, and the preview shows the
+ // space as the agent writes it. Nothing new is drawn here: the Dock and the editor's own preview do the work.
+ var agent = boot.agent || null;
+ function openDock() {
+ try { if (window.OpenVibeNavbar && agent && agent.task) window.OpenVibeNavbar.openDock('agent', { task: agent.task }); } catch (e) { /* the Dock is optional */ }
+ }
+ /** The files the agent wrote: they replace the editor's, unless the person has unsaved edits of their own. */
+ function agentFiles(list) {
+ if (!list || !list.length || dirty) return false;
+ var next = list.map(function (f) { return { path: f.path, content: f.content }; });
+ var same = next.length === files.length && next.every(function (f, i) { return f.path === files[i].path && f.content === files[i].content; });
+ if (same) return false;
+ files = next;
+ current = 'index.html';
+ setText((fileOf('index.html') || { content: '' }).content, 'index.html');
+ renderTabs();
+ run();
+ return true;
+ }
+ function agentPoll() {
+ if (!agent) return;
+ call('GET', '/agent').then(function (j) {
+ agentFiles(j.files);
+ if (j.publish && j.publish.state === 'pending') status('Your agent wants to publish this space — answer it in your Dock');
+ if (j.status === 'running' || j.status === 'waiting') { setTimeout(agentPoll, 3000); return; }
+ building(false);
+ if (j.status === 'done') status(j.message ? 'Your agent is done: ' + j.message : 'Your agent is done');
+ else if (j.status === 'failed') status('Your agent could not build this space', 'bad');
+ else status('');
+ }, function () { setTimeout(agentPoll, 5000); });
+ }
+ if (agent) {
+ var title = $('ed-building-title');
+ if (title) title.textContent = 'Your agent is building this…';
+ if (agent.state === 'running' || agent.state === 'waiting') {
+ building(true, '');
+ openDock();
+ setTimeout(agentPoll, 1500);
+ }
+ }
+
// ── Settings ──
// ── Settings ──
var dialog = $('ed-dialog'), form = $('ed-settings-form');
var dialog = $('ed-dialog'), form = $('ed-settings-form');
$('ed-settings').addEventListener('click', function () {
$('ed-settings').addEventListener('click', function () {
@@ -9,6 +9,10 @@
@@ -9,6 +9,10 @@
* space.site.config read, set and reset a site's response headers, redirects and SPA fallback
* space.site.config read, set and reset a site's response headers, redirects and SPA fallback
* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
* space.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
* space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)
* space.resource.control delete a site, deploy or domain for a person (POST /api/v1/resources/control; plan T13 step 10)
+ * space.build.read the person's own spaces and their files, as their agent works on them (plan 2026-10-22 §Z, stage Z1)
+ * space.build.create make a new space for the person (their agent, OpenVibe.Actor)
+ * space.build.write write the files of one of the person's own spaces
+ * space.build.publish ask to publish one of them; the person answers before anything goes live
*
*
* A service token is judged by its capability AND by the project role of the principal it acts
* A service token is judged by its capability AND by the project role of the principal it acts
* as (domain/access.js). Browsers are judged by their role alone.
* as (domain/access.js). Browsers are judged by their role alone.
@@ -24,6 +28,11 @@ const CAPABILITIES = Object.freeze({
@@ -24,6 +28,11 @@ const CAPABILITIES = Object.freeze({
SITE_CONFIG: 'space.site.config',
SITE_CONFIG: 'space.site.config',
RESOURCE_READ: 'space.resource.read',
RESOURCE_READ: 'space.resource.read',
RESOURCE_CONTROL: 'space.resource.control',
RESOURCE_CONTROL: 'space.resource.control',
+ // The builder as the person's one agent works on it (plan 2026-10-22 §Z, stage Z1)
+ BUILD_READ: 'space.build.read',
+ BUILD_CREATE: 'space.build.create',
+ BUILD_WRITE: 'space.build.write',
+ BUILD_PUBLISH: 'space.build.publish',
});
});
/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
/** → { allowed, code, reason } (capabilities.check(); space.* since openvibe-contracts 0.149.0). */
@@ -9,6 +9,15 @@
@@ -9,6 +9,15 @@
* space.domain.verified a custom domain's TXT record was found; the domain is now served
* space.domain.verified a custom domain's TXT record was found; the domain is now served
* space.app.claimed a guest draft was claimed by a signed-in person (it is theirs now)
* space.app.claimed a guest draft was claimed by a signed-in person (it is theirs now)
*
*
+ * agent.step.started the person's agent started a step on one of their spaces (plan 2026-10-22 §Z, stage Z1)
+ * agent.step.updated the same step, brought forward
+ * agent.step.ended the step ended
+ *
+ * The agent.* namespace belongs to the network (like provider.*), so a first-party mode may publish into it:
+ * OpenVibe.Actor keeps the one timeline behind the Dock, and every mode (Run, Space, …) publishes its own steps
+ * with its own product. Space's manifest does not list them (one producer per event type, contracts' rule):
+ * Actor owns the family.
+ *
* The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db
* The SDK writes each row inside the change's own transaction (emit joins the ambient openvibe-sdk/db
* transaction), so an event exists if and only if its change committed. The relay publishes with
* transaction), so an event exists if and only if its change committed. The relay publishes with
* Space's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and
* Space's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and
@@ -17,7 +26,8 @@
@@ -17,7 +26,8 @@
*/
*/
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
-const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed']);
+const EVENT_TYPES = Object.freeze(['space.deploy.created', 'space.deploy.activated', 'space.deploy.failed', 'space.domain.verified', 'space.app.claimed',
+ 'agent.step.started', 'agent.step.updated', 'agent.step.ended']);
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
return createServiceOutbox({
return createServiceOutbox({
@@ -150,6 +150,21 @@ function load(env = process.env) {
@@ -150,6 +150,21 @@ function load(env = process.env) {
sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
sites: { extraReservedNames: list(env.SPACE_RESERVED_SITE_NAMES).map((d) => d.toLowerCase()) },
+ // The builder (plan 2026-10-22 section Z, stage Z1): who builds a space when the person describes one.
+ // 'legacy' Space's own AI loop (the way it was before the agent became the builder)
+ // 'agent' a task of the person's one agent (OpenVibe.Actor), with the space_* tools
+ // The old path stays until the new one is proven; nothing is deleted. Production runs 'agent' once the
+ // checks pass (SPACE_BUILDER=agent in /etc/openvibe/space.env).
+ builder: {
+ mode: ['agent', 'legacy'].includes(String(env.SPACE_BUILDER || 'legacy').trim().toLowerCase())
+ ? String(env.SPACE_BUILDER || 'legacy').trim().toLowerCase() : 'legacy',
+ // OpenVibe.Actor, the person's one agent (its API answers the task and the tools it uses).
+ actorUrl: trim(env.OV_ACTOR_INTERNAL_URL || env.OV_ACTOR_URL || 'http://127.0.0.1:4950'),
+ timeoutMs: int(env.SPACE_ACTOR_TIMEOUT_MS, 15000),
+ // How often Space asks Actor how the task is doing (the agent's steps reach the Dock through Events).
+ pollMs: int(env.SPACE_ACTOR_POLL_MS, 3000),
+ },
+
// Spaces (server/spaces/): apps people make here. Making, editing, previewing and the AI builder are open to
// Spaces (server/spaces/): apps people make here. Making, editing, previewing and the AI builder are open to
// everyone signed in; publishing follows publicHosting (or staff). The limits keep one person or one space from
// everyone signed in; publishing follows publicHosting (or staff). The limits keep one person or one space from
// filling the database or spending everyone's AI.
// filling the database or spending everyone's AI.
@@ -72,6 +72,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -72,6 +72,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });
ctx.spacesAi = require('./spaces/ai').createSpacesAi({ config, client: spacesAiClient, log });
ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
ctx.spaceData = require('./spaces/data').createSpaceData({ store, config, spaces: ctx.spaces, ai: ctx.spacesAi });
ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });
ctx.spaceFunctions = require('./spaces/functions').createSpaceFunctions({ data: ctx.spaceData, workers: config.spaces.fnWorkers, log });
+ // The person's one agent (plan 2026-10-22 §Z, stage Z1): the builder API the agent works through, the
+ // steps it publishes into the network's agent namespace, and the watcher that follows its task.
+ ctx.spaceSteps = require('./spaces/steps').createSpaceSteps({ outbox, config, log });
+ ctx.spaceAgent = require('./spaces/agent').createSpaceAgent({ config, fetchImpl, log });
+ ctx.spaceAgentWatch = require('./spaces/agent-watch').createAgentWatcher({ config, spaces: ctx.spaces, agent: ctx.spaceAgent, steps: ctx.spaceSteps, log });
// Guest drafts (openvibe.website, plan 5.9): the job that removes the drafts whose week is up.
// Guest drafts (openvibe.website, plan 5.9): the job that removes the drafts whose week is up.
ctx.guestCleanup = require('./spaces/guest-cleanup').createGuestCleanup({ store, config, log });
ctx.guestCleanup = require('./spaces/guest-cleanup').createGuestCleanup({ store, config, log });
{
{
@@ -147,6 +152,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -147,6 +152,11 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
}
}
app.use('/api/v1/apps', rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false }), require('./spaces/api').createSpacesApi({ config, spaces: ctx.spaces, data: ctx.spaceData, sites, takedowns, log }));
app.use('/api/v1/apps', rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false }), require('./spaces/api').createSpacesApi({ config, spaces: ctx.spaces, data: ctx.spaceData, sites, takedowns, log }));
+ // Space's builder API (plan 2026-10-22 §Z, stage Z1): what the person's own agent (OpenVibe.Actor)
+ // works through, and where the person's answer to its publish request lands. It is mounted before
+ // /api/v1, which answers everything else.
+ app.use('/api/v1/builder/spaces', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./spaces/agent-api').createSpacesBuilderApi({ config, spaces: ctx.spaces, steps: ctx.spaceSteps, log }));
+ app.use('/api/v1/agent/approvals', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), viewers.middleware('api'), require('./spaces/agent-api').createApprovalsApi({ spaces: ctx.spaces, steps: ctx.spaceSteps, log }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => Promise.resolve(tenant.preview(req, res)).catch(next));
{
{
@@ -29,6 +29,10 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -29,6 +29,10 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.outbox.start();
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)
app.locals.hosting.guestCleanup.start(); // the guest drafts whose week is up (off with SPACE_GUEST_DRAFTS=off)
+ // The person's own agent (plan 2026-10-22 §Z, stage Z1): the watcher follows the tasks that build spaces
+ // (off unless SPACE_BUILDER=agent and OpenVibe.Actor is there).
+ const agentWatch = app.locals.hosting.spaceAgentWatch;
+ if (agentWatch && agentWatch.enabled) agentWatch.start();
// Sharing (openvibe-sdk/share): expire members and transfers, then send queued share events and notifications.
// Sharing (openvibe-sdk/share): expire members and transfers, then send queued share events and notifications.
const sharingTimer = setInterval(() => { app.locals.hosting.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
const sharingTimer = setInterval(() => { app.locals.hosting.sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
if (sharingTimer.unref) sharingTimer.unref();
if (sharingTimer.unref) sharingTimer.unref();
@@ -49,7 +53,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -49,7 +53,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
- stop: [async () => { if (subscriptions) subscriptions.stop(); clearInterval(sharingTimer); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
+ stop: [async () => { if (subscriptions) subscriptions.stop(); clearInterval(sharingTimer); if (agentWatch) agentWatch.stop(); app.locals.hosting.worker.stop(); app.locals.hosting.guestCleanup.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
close: [
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
+'use strict';
+/**
+ * Space's builder API: what the person's own agent works through (plan 2026-10-22 §Z stage Z1).
+ *
+ * GET /api/v1/builder/spaces the person's own spaces
+ * POST /api/v1/builder/spaces make a new one
+ * GET /api/v1/builder/spaces/:id one of them
+ * GET /api/v1/builder/spaces/:id/files its files
+ * GET /api/v1/builder/spaces/:id/files/:path one file
+ * PUT /api/v1/builder/spaces/:id/files write its files (they replace the space's files as a whole)
+ * GET /api/v1/builder/spaces/:id/preview where it can be seen now
+ * POST /api/v1/builder/spaces/:id/publish ask to publish it (the person answers; nothing goes live until they do)
+ *
+ * (The address is /api/v1/builder/spaces because /api/v1/spaces was the forum's old API, which lives on
+ * OpenVibe.Community now and is still redirected there.)
+ *
+ * The caller is OpenVibe.Actor, acting for the person it names in X-OV-Subject. Space reads that person and
+ * touches nothing else: another person's space is the same 404 as one that does not exist. The capabilities
+ * (space.build.read, space.build.create, space.build.write, space.build.publish) are checked on the token; the
+ * writes go through the same path as Space's own editor, with the same limits and the same audit.
+ *
+ * POST /api/v1/agent/approvals/:id/decide the person's answer to the agent's request to publish
+ *
+ * The answer comes from the Dock: OpenVibe.Actor passes it on with the person's own token, so Space sees the
+ * person who owns the space, never a service deciding for them.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { guard } = require('../auth/viewer');
+const { ApiError } = require('../http/errors');
+
+/** The person a call is for: the signed-in person, or the one a first-party service names in X-OV-Subject. */
+function personOf(req) {
+ const v = req.viewer;
+ if (!v) return null;
+ if (v.kind === 'user') return v.subject;
+ if (v.kind === 'service' && typeof v.subject === 'string' && v.subject) return v.subject;
+ return null;
+}
+
+function createSpacesBuilderApi({ config, spaces, steps = null, log = console }) {
+ const router = express.Router();
+ const json = express.json({ limit: '256kb' });
+
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const fail = (req, res, err) => {
+ if (err instanceof ApiError || (err && Number.isInteger(err.status) && err.code)) {
+ return contracts.http.sendProblem(res, err.status || 400, err.code || 'space.error', { detail: err.message, ctx: req.ov });
+ }
+ log.error('[Spaces builder]', err && err.stack ? err.stack : err);
+ return contracts.http.sendProblem(res, 500, 'internal', { detail: 'Something went wrong on our side', ctx: req.ov });
+ };
+ const run = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined && !res.headersSent) res.status(200).set('Cache-Control', 'no-store').json(out); } catch (err) { fail(req, res, err); } };
+
+ /** The person's own space by id. Another person's space is the same 404 as one that does not exist. */
+ async function ownSpace(req) {
+ const person = personOf(req);
+ if (!person) throw new ApiError(401, 'auth.required', 'this work runs for one person; sign in');
+ const space = await spaces.ownedOf(person, req.params.id);
+ if (!space) throw new ApiError(404, 'space.not_found', 'no such space');
+ return { person, space };
+ }
+ const out = (space) => ({
+ id: space.id,
+ slug: space.slug,
+ name: space.name,
+ description: space.description || '',
+ published: Boolean(space.site_id),
+ url: space.site_id ? spaces.urlOf(space) : `${config.baseUrl}/spaces/${encodeURIComponent(space.slug)}/edit`,
+ updated_at: new Date(Number(space.updated_at)).toISOString(),
+ });
+
+ router.get('/', guard('space.build.read'), run(async (req) => {
+ const person = personOf(req);
+ if (!person) throw new ApiError(401, 'auth.required', 'sign in to see your spaces');
+ const mine = await spaces.mineOf(person);
+ return { spaces: mine.map(out) };
+ }));
+
+ router.post('/', guard('space.build.create'), json, run(async (req) => {
+ const person = personOf(req);
+ if (!person) throw new ApiError(401, 'auth.required', 'sign in to make a space');
+ const body = req.body || {};
+ const templates = require('./templates');
+ const blank = templates.get('blank');
+ const space = await spaces.create(spaces.viewerOf(person, { username: '', name: '' }), {
+ name: String(body.name || 'New space').slice(0, 40),
+ description: String(body.description || '').slice(0, 400),
+ capabilities: blank.capabilities,
+ files: templates.files('blank'),
+ template: null,
+ });
+ return { space: out(space) };
+ }));
+
+ router.get('/:id', guard('space.build.read'), run(async (req) => ({ space: out((await ownSpace(req)).space) })));
+
+ router.get('/:id/files', guard('space.build.read'), run(async (req) => {
+ const { space } = await ownSpace(req);
+ return { files: await spaces.files(space) };
+ }));
+
+ router.get('/:id/files/:path(*)', guard('space.build.read'), run(async (req) => {
+ const { space } = await ownSpace(req);
+ const path = String(req.params.path || '').replace(/^\.?\//, '');
+ const files = await spaces.files(space);
+ const one = files.find((f) => f.path === path);
+ if (!one) throw new ApiError(404, 'space.file_not_found', 'no such file in this space');
+ return { files: [one] };
+ }));
+
+ router.put('/:id/files', guard('space.build.write'), json, run(async (req) => {
+ const { person, space } = await ownSpace(req);
+ const body = req.body || {};
+ const files = await spaces.saveFiles(spaces.viewerOf(person, space), space, body.files);
+ if (steps) await steps.working({ space, person });
+ return { files };
+ }));
+
+ router.get('/:id/preview', guard('space.build.read'), run(async (req) => {
+ const { space } = await ownSpace(req);
+ const files = await spaces.files(space);
+ return {
+ url: space.site_id ? spaces.urlOf(space) : `${config.baseUrl}/spaces/${encodeURIComponent(space.slug)}/edit`,
+ files: files.map((f) => f.path),
+ published: Boolean(space.site_id),
+ };
+ }));
+
+ router.post('/:id/publish', guard('space.build.publish'), json, run(async (req) => {
+ const { person, space } = await ownSpace(req);
+ // While public hosting is off, nothing can go live (an owner decision): the agent is told now, instead
+ // of asking the person to approve something that could not succeed.
+ const staff = req.viewer.kind === 'user' && req.viewer.staff;
+ if (!config.publicHosting && !staff) {
+ throw new ApiError(403, 'space.not_launched', 'Publishing a space at its own address opens with OpenVibe.Space\'s public hosting; until then the space runs in its editor.');
+ }
+ const ask = await spaces.askPublish(person, space);
+ if (steps) await steps.publishAsked({ space, person, request: ask });
+ return { state: ask.state, approval_id: ask.id, url: ask.url || null, reason: ask.reason || null };
+ }));
+
+ return router;
+}
+
+/**
+ * The person's answer to the agent's request to publish a space. OpenVibe.Actor passes it on with the person's
+ * own token (the Dock's "Needs you" card), so Space sees the person who owns the space.
+ *
+ * Approving publishes the space now; declining leaves it as it is. Nothing goes live without the answer.
+ */
+function createApprovalsApi({ spaces, steps = null, log = console }) {
+ const router = express.Router();
+ const json = express.json({ limit: '16kb' });
+
+ router.post('/:id/decide', guard('space.build.publish'), json, async (req, res) => {
+ try {
+ const person = personOf(req);
+ if (!person) return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'sign in to answer', ctx: req.ov });
+ const decision = String((req.body || {}).decision || '').trim().toLowerCase();
+ if (decision !== 'approve' && decision !== 'decline') {
+ return contracts.http.sendProblem(res, 422, 'space.approval.invalid', { detail: 'decision: approve or decline', ctx: req.ov });
+ }
+ const out = await spaces.decidePublish(person, req.params.id, decision);
+ if (out.error) return contracts.http.sendProblem(res, out.status || 404, 'space.approval.not_found', { detail: out.error, ctx: req.ov });
+ const space = out.space || null;
+ if (steps && space) {
+ await steps.publishAnswered({ space, person, request: { id: req.params.id }, approved: out.state === 'approved', reason: out.reason || null });
+ }
+ res.status(200).set('Cache-Control', 'no-store').json({ state: out.state, url: out.url || null, reason: out.reason || null });
+ } catch (err) {
+ log.error('[Spaces approvals]', err && err.stack ? err.stack : err);
+ return contracts.http.sendProblem(res, 500, 'internal', { detail: 'Something went wrong on our side', ctx: req.ov });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSpacesBuilderApi, createApprovalsApi, personOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * The watcher (plan 2026-10-22 §Z stage Z1): Space follows the task of the person's agent that builds a space.
+ *
+ * Every few seconds it asks OpenVibe.Actor how the task is doing, and it publishes the step into the network's
+ * agent namespace (server/spaces/steps.js), so the person sees it in the one timeline behind the Dock. The space's
+ * own page asks Actor the same question while it is open, and shows the space's live preview.
+ *
+ * The watcher is the safety net: when the person closes the page, the task keeps running and the Dock still shows
+ * what it is doing.
+ */
+
+/** Actor's task states, and the ones Space keeps (running, waiting, done, failed). */
+const { STATE } = require('./agent');
+
+function createAgentWatcher({ config, spaces, agent, steps = null, log = console }) {
+ const enabled = Boolean(agent && agent.enabled) && config.builder.mode === 'agent';
+ const intervalMs = config.builder.pollMs;
+ let timer = null;
+ let busy = false;
+
+ /** One round: every space whose agent task is still open, asked about its task. */
+ async function tick() {
+ if (busy) return;
+ busy = true;
+ try {
+ const rows = await spaces.agentRunning();
+ for (const space of rows) {
+ const person = space.owner_subject;
+ const taskId = space.agent_task_id;
+ const t = await agent.task({ person, id: taskId });
+ if (!t) {
+ await spaces.setAgentState(space, 'failed');
+ if (steps) await steps.ended({ space, person, taskId, ok: false, reason: 'the task is gone' });
+ continue;
+ }
+ const state = STATE[t.state] || 'running';
+ if (state !== space.agent_task_state) {
+ await spaces.setAgentState(space, state);
+ if (state === 'running' && steps) await steps.working({ space, person, taskId });
+ }
+ if (state === 'done' || state === 'failed') {
+ if (steps) {
+ await steps.ended({
+ space, person, taskId, ok: state === 'done',
+ reason: state === 'done' ? null : (t.error && (t.error.detail || t.error.code)) || t.error || t.state || 'the task did not finish',
+ });
+ }
+ }
+ }
+ } catch (err) {
+ log.warn('[Spaces agent]', (err && err.message) || err);
+ } finally {
+ busy = false;
+ }
+ }
+
+ return {
+ enabled,
+ tick,
+ /** The watcher runs only when the agent is the builder; otherwise the old AI loop stays in charge. */
+ start() {
+ if (!enabled || timer) return;
+ timer = setInterval(() => { tick().catch(() => {}); }, intervalMs);
+ if (timer.unref) timer.unref();
+ },
+ stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ },
+ };
+}
+
+module.exports = { createAgentWatcher, STATE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+/**
+ * Space → OpenVibe.Actor, for the person's one agent (plan 2026-10-22 §Z stage Z1).
+ *
+ * Space's builder is not its own AI any more: "Describe it and OpenVibe AI builds it" starts a task of the
+ * person's agent (OpenVibe.Actor's engine), and the Dock's one timeline shows the step while Space shows the
+ * space's live preview. Space asks Actor for the task and reads it back; the agent works on the space through
+ * Space's builder API (the space_* tools).
+ *
+ * The task is the person's: Space calls Actor with its own service token (audience openvibe.actor) and names the
+ * person in X-OV-Subject, so the task is theirs (their allowance, their task list, Space recorded as the service
+ * that started it). Actor answers 429/422/5xx in its own words; those pass through to the person.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SCOPE = 'actor.task.create actor.task.read';
+
+/**
+ * What a task's state means for a space: running, waiting (the task itself asks the person something), done or
+ * failed. Actor's own states are the source; a space only keeps these four.
+ */
+const STATE = {
+ queued: 'running',
+ running: 'running',
+ verifying: 'running',
+ needs_you: 'waiting',
+ succeeded: 'done',
+ failed: 'failed',
+ cancelled: 'failed',
+};
+
+/**
+ * The instruction Actor receives: what to build, in plain words, with the space it works on. The tools are named
+ * so the model does not have to guess them.
+ */
+function taskText(space, prompt, files) {
+ const have = (files || []).map((f) => f.path).join(', ');
+ return [
+ 'Build a space on OpenVibe.Space for the person who asked for it.',
+ `The space is ${space.id} ("${space.name}"). Its files now are: ${have || 'none yet'}.`,
+ 'Write the space with space_files_write (index.html first, then the rest): the files you send replace the space\'s files as a whole, so send every file it should have.',
+ 'Read what is there with space_files_read, and show where the space lives with space_preview.',
+ 'Ask before it goes live with space_publish: the person answers, so nothing is published until they do.',
+ 'When the space is done, say in one line what it is and where they can see it.',
+ '',
+ 'What the person asked for:',
+ String(prompt || '').trim(),
+ ].join('\n');
+}
+
+function createSpaceAgent({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const base = String((config.builder && config.builder.actorUrl) || '').replace(/\/+$/, '');
+ const clientSecret = config.oauth && config.oauth.clientSecret;
+ const enabled = Boolean(base && clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: (config.oauth && config.oauth.clientId) || 'space',
+ clientSecret,
+ audience: 'openvibe.actor',
+ scope: SCOPE,
+ fetchImpl,
+ }) : null;
+ const timeoutMs = (config.builder && config.builder.timeoutMs) || 15_000;
+
+ /** One call to Actor for one person. */
+ async function call(method, path, person, body, extraHeaders) {
+ const headers = { Accept: 'application/json', 'X-OV-Subject': person, ...(await tokens.authHeaders()), ...(extraHeaders || {}) };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ log.warn('[Spaces agent] Actor did not answer:', (err && err.message) || err);
+ return { status: 502, data: { code: 'actor.unavailable', detail: 'OpenVibe.Actor did not answer. Try again in a moment.' } };
+ }
+ const text = await res.text().catch(() => '');
+ let data = null;
+ try { data = text ? JSON.parse(text) : null; } catch { data = null; }
+ return { status: res.status, data: data || {} };
+ }
+
+ /**
+ * Start a task for the person. Answers { id, state } or { error: { code, message } } when Actor refused
+ * (its own words: an allowance that is used up, a budget, a task that is too long).
+ */
+ async function createTask({ person, task, idempotencyKey }) {
+ if (!enabled) return { error: { code: 'actor.unavailable', message: 'Your agent is not available right now. Start from a template instead.' } };
+ const key = String(idempotencyKey || '').slice(0, 128) || `space:${person}:${Date.now()}`;
+ const out = await call('POST', '/api/v1/tasks', person, { task, idempotency_key: key }, { 'Idempotency-Key': key });
+ if (out.status >= 400) {
+ const d = out.data || {};
+ return { error: { code: d.code || 'actor.refused', message: String(d.detail || d.title || 'Your agent could not start that task.').slice(0, 300) } };
+ }
+ const id = out.data && out.data.id;
+ if (!id || !TASK_ID.test(String(id))) return { error: { code: 'actor.invalid_response', message: 'Your agent did not answer with a task.' } };
+ return { id, state: out.data.state || 'queued' };
+ }
+
+ /** The task as it is now: { id, state, title, result } or null when Actor does not know it. */
+ async function task({ person, id }) {
+ if (!enabled) return null;
+ if (!TASK_ID.test(String(id || ''))) return null;
+ const out = await call('GET', `/api/v1/tasks/${encodeURIComponent(id)}`, person);
+ if (out.status >= 400) return null;
+ const d = out.data || {};
+ return {
+ id: d.id || id,
+ state: d.state || 'queued',
+ title: typeof d.task === 'string' ? d.task.slice(0, 200) : '',
+ result: d.result && typeof d.result === 'object' ? d.result : null,
+ error: d.error_code || d.error || null,
+ };
+ }
+
+ return { enabled, createTask, task, taskText, stateOf: (s) => STATE[s] || null };
+}
+
+module.exports = { createSpaceAgent, taskText, TASK_ID, SCOPE, STATE };
@@ -145,6 +145,11 @@ function createSpacesPages(ctx) {
@@ -145,6 +145,11 @@ function createSpacesPages(ctx) {
}
}
}];
}];
const buildsLeft = async (req) => (signedIn(req) ? Math.max(0, limits.aiBuildsPerDay - await spaces.used(`build:${req.viewer.subject}`)) : 0);
const buildsLeft = async (req) => (signedIn(req) ? Math.max(0, limits.aiBuildsPerDay - await spaces.used(`build:${req.viewer.subject}`)) : 0);
+ /**
+ * The agent is the builder (plan 2026-10-22 §Z, stage Z1): the flag says so, and OpenVibe.Actor is there to
+ * answer. Guests never get an agent (they have no account, and the agent works for the person).
+ */
+ const agentOn = () => Boolean(config.builder && config.builder.mode === 'agent' && ctx.spaceAgent && ctx.spaceAgent.enabled);
const settingsOut = (s) => ({ id: s.id, slug: s.slug, name: s.name, description: s.description, instructions: s.instructions, capabilities: spaces.capsOf(s.capabilities), visibility: s.visibility, published: Boolean(s.site_id), url: spaces.urlOf(s), building: Boolean(s.building_run_id) });
const settingsOut = (s) => ({ id: s.id, slug: s.slug, name: s.name, description: s.description, instructions: s.instructions, capabilities: spaces.capsOf(s.capabilities), visibility: s.visibility, published: Boolean(s.site_id), url: spaces.urlOf(s), building: Boolean(s.building_run_id) });
// ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────
// ── Guest drafts (openvibe.website, plan 5.9) ────────────────────────────────────────────────────────────
@@ -206,12 +211,32 @@ function createSpacesPages(ctx) {
@@ -206,12 +211,32 @@ function createSpacesPages(ctx) {
}
}
const again = (error) => html(res, 422, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: 0, error, prompt }));
const again = (error) => html(res, 422, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: 0, error, prompt }));
if (prompt.length < 8) return again('Describe your space in a sentence or two (what it does, who uses it, how it should look).');
if (prompt.length < 8) return again('Describe your space in a sentence or two (what it does, who uses it, how it should look).');
- if (!ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');
- // The abuse screen: what is asked of the AI must pass it (a guest's prompt passes the same checks).
+ // The builder: the person's own agent (plan 2026-10-22 §Z, stage Z1) or Space's old AI loop, while the
+ // flag says which one it is. The old path is untouched and still works.
+ const withAgent = agentOn();
+ if (!withAgent && !ai.enabled()) return again('OpenVibe AI is not available right now. Start from a template instead.');
+ // The abuse screen: what is asked of the builder must pass it (a guest's prompt passes the same checks).
contentCheck.refuse(contentCheck.promptReason(prompt));
contentCheck.refuse(contentCheck.promptReason(prompt));
if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) return again(`You have used today's ${limits.aiBuildsPerDay} AI builds. Start from a template, or try again tomorrow.`);
if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) return again(`You have used today's ${limits.aiBuildsPerDay} AI builds. Start from a template, or try again tomorrow.`);
const t = templates.get('blank');
const t = templates.get('blank');
const space = await spaces.create(req.viewer, { name: 'New space', description: '', capabilities: t.capabilities, files: templates.files('blank'), template: null });
const space = await spaces.create(req.viewer, { name: 'New space', description: '', capabilities: t.capabilities, files: templates.files('blank'), template: null });
+ if (withAgent) {
+ // The space is a task of the person's own agent: it writes the files, and the Dock shows the step.
+ const person = spaces.personOf(req.viewer);
+ const task = await ctx.spaceAgent.createTask({
+ person: person.subject,
+ task: ctx.spaceAgent.taskText(space, prompt, await spaces.files(space)),
+ idempotencyKey: `space:${space.id}`,
+ });
+ if (task.error) {
+ // Nothing was built: the space goes away with the empty promise.
+ await spaces.remove(req.viewer, space).catch(() => {});
+ return again(task.error.message);
+ }
+ await spaces.setAgentTask(space, task.id, 'running');
+ await ctx.spaceSteps.started({ space, person: person.subject, taskId: task.id });
+ return res.redirect(303, `/spaces/${space.slug}/edit`);
+ }
const runId = await ai.build(spaces.personOf(req.viewer), { prompt, spaceId: space.id });
const runId = await ai.build(spaces.personOf(req.viewer), { prompt, spaceId: space.id });
await spaces.startBuild(space, runId, prompt);
await spaces.startBuild(space, runId, prompt);
res.redirect(303, `/spaces/${space.slug}/edit`);
res.redirect(303, `/spaces/${space.slug}/edit`);
@@ -351,6 +376,21 @@ function createSpacesPages(ctx) {
@@ -351,6 +376,21 @@ function createSpacesPages(ctx) {
if (!room.ok) throw new ApiError(429, 'quota', room.reason === 'ip'
if (!room.ok) throw new ApiError(429, 'quota', room.reason === 'ip'
? `you have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds; try again tomorrow`
? `you have used today's ${guestLimits.aiBuildsPerIpPerDay} AI builds; try again tomorrow`
: `this draft has had its ${guestLimits.aiEditsPerDraft} AI changes; try again tomorrow`);
: `this draft has had its ${guestLimits.aiEditsPerDraft} AI changes; try again tomorrow`);
+ } else if (agentOn()) {
+ // The agent changes the space, as a task of the person's own agent (plan 2026-10-22 §Z stage Z1).
+ const person = spaces.personOf(req.viewer);
+ if (!(await spaces.spend(`build:${person.subject}`, limits.aiBuildsPerDay))) {
+ throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);
+ }
+ const task = await ctx.spaceAgent.createTask({
+ person: person.subject,
+ task: ctx.spaceAgent.taskText(space, prompt, await spaces.files(space)),
+ idempotencyKey: `space:${space.id}:${ctx.store.now()}`,
+ });
+ if (task.error) throw new ApiError(502, 'actor.unavailable', task.error.message);
+ await spaces.setAgentTask(space, task.id, 'running');
+ await ctx.spaceSteps.started({ space, person: person.subject, taskId: task.id });
+ return { building: true, task: task.id };
} else if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) {
} else if (!(await spaces.spend(`build:${req.viewer.subject}`, limits.aiBuildsPerDay))) {
throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);
throw new ApiError(429, 'quota', `you have used today's ${limits.aiBuildsPerDay} AI builds; try again tomorrow`);
}
}
@@ -384,6 +424,25 @@ function createSpacesPages(ctx) {
@@ -384,6 +424,25 @@ function createSpacesPages(ctx) {
await spaces.failBuild(space, runId);
await spaces.failBuild(space, runId);
return { status: 'failed', message: r.error === 'quota.exceeded' ? 'Your OpenVibe AI allowance for today is used up.' : 'The AI could not build that. Try again, or describe it differently.' };
return { status: 'failed', message: r.error === 'quota.exceeded' ? 'Your OpenVibe AI allowance for today is used up.' : 'The AI could not build that. Try again, or describe it differently.' };
}));
}));
+ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z, stage Z1): the editor asks
+ // it how it is doing, so the page can show the space as the agent writes it.
+ router.get('/spaces/:slug/agent', ...api(async (req, res, who) => {
+ const space = who.space;
+ // A guest draft has no agent (there is no person to run one).
+ if (who.guest || !space.agent_task_id) return { status: 'idle' };
+ const person = spaces.personOf(req.viewer);
+ const t = await ctx.spaceAgent.task({ person: person.subject, id: space.agent_task_id });
+ const status = t ? (ctx.spaceAgent.stateOf(t.state) || 'running') : (space.agent_task_state || 'running');
+ const fresh = await spaces.get(space.id);
+ const publish = space.agent_publish_id ? await spaces.publishRequest(person.subject, space.agent_publish_id) : null;
+ return {
+ status,
+ task: space.agent_task_id,
+ files: await spaces.files(fresh),
+ publish: publish ? { id: publish.id, state: publish.state } : null,
+ message: t && t.result && typeof t.result.answer === 'string' ? t.result.answer.slice(0, 300) : '',
+ };
+ }));
router.post('/spaces/:slug/publish', ...api(async (req, res, who) => {
router.post('/spaces/:slug/publish', ...api(async (req, res, who) => {
// A guest draft is not published: it has no owner to publish under, and it is static.
// A guest draft is not published: it has no owner to publish under, and it is static.
if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft cannot be published; sign in and keep it first');
if (who.guest) throw new ApiError(403, 'space.guest_cannot_publish', 'a guest draft cannot be published; sign in and keep it first');
@@ -186,6 +186,9 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -186,6 +186,9 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
// owned space by its slug. A guest carries its secret with every call.
// owned space by its slug. A guest carries its secret with every call.
base: `/spaces/${guest ? space.id : space.slug}`,
base: `/spaces/${guest ? space.id : space.slug}`,
guest: guest ? { id: space.id, key: guest.key } : null,
guest: guest ? { id: space.id, key: guest.key } : null,
+ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z, stage Z1): the editor
+ // opens the Dock at it and follows it until it ends.
+ agent: space.agent_task_id ? { task: space.agent_task_id, state: space.agent_task_state || 'running' } : null,
};
};
// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
const claim = guest ? (guest.signedIn
const claim = guest ? (guest.signedIn
@@ -221,7 +224,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -221,7 +224,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
<details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details>
<details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details>
</section>
</section>
</div>
</div>
- <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2>OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
+ <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2 id="ed-building-title">OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
<dialog class="sp-ed-dialog" id="ed-dialog">
<dialog class="sp-ed-dialog" id="ed-dialog">
<form method="dialog" id="ed-settings-form">
<form method="dialog" id="ed-settings-form">
<h2>Settings</h2>
<h2>Settings</h2>
@@ -75,6 +75,17 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -75,6 +75,17 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
counter: db.prepare(`INSERT INTO space_app_counters (scope, day, n) VALUES (?, ?, 1)
ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),
ON CONFLICT (scope, day) DO UPDATE SET n = space_app_counters.n + 1 RETURNING n`),
counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
counterGet: db.prepare('SELECT n FROM space_app_counters WHERE scope = ? AND day = ?'),
+ // The task of the person's own agent that builds this space (plan 2026-10-22 §Z stage Z1).
+ setAgentTask: db.prepare('UPDATE space_apps SET agent_task_id = ?, agent_task_state = ?, updated_at = ? WHERE id = ?'),
+ setAgentState: db.prepare('UPDATE space_apps SET agent_task_state = ?, updated_at = ? WHERE id = ?'),
+ setAgentStep: db.prepare('UPDATE space_apps SET agent_step_id = ?, updated_at = ? WHERE id = ?'),
+ setAgentPublish: db.prepare('UPDATE space_apps SET agent_publish_id = ?, updated_at = ? WHERE id = ?'),
+ agentTasks: db.prepare("SELECT * FROM space_apps WHERE agent_task_id IS NOT NULL AND agent_task_state IN ('running','waiting') AND status = 'active'"),
+ // A publish the agent asked for: the person answers it (migrations/0011_space_agent.sql).
+ addPublish: db.prepare(`INSERT INTO space_publish_requests (id, space_id, owner_subject, state, created_at) VALUES (?, ?, ?, 'pending', ?)`),
+ publishById: db.prepare('SELECT * FROM space_publish_requests WHERE id = ?'),
+ publishPending: db.prepare("SELECT * FROM space_publish_requests WHERE space_id = ? AND state = 'pending' ORDER BY created_at DESC LIMIT 1"),
+ decidePublish: db.prepare("UPDATE space_publish_requests SET state = ?, decided_at = ? WHERE id = ? AND state = 'pending'"),
// Guest drafts (openvibe.website, plan 5.9): no owner yet, a secret of their own, and a time they live until.
// Guest drafts (openvibe.website, plan 5.9): no owner yet, a secret of their own, and a time they live until.
insertGuest: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,
insertGuest: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,
template, forked_from, visibility, status, created_at, updated_at, guest_hash, guest_ip, guest_expires_at)
template, forked_from, visibility, status, created_at, updated_at, guest_hash, guest_ip, guest_expires_at)
@@ -347,6 +358,96 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -347,6 +358,96 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
}
}
async function used(scope) { const cur = await q.counterGet.get(scope, today()); return cur ? cur.n : 0; }
async function used(scope) { const cur = await q.counterGet.get(scope, today()); return cur ? cur.n : 0; }
+ // ── The person's one agent (plan 2026-10-22 §Z stage Z1) ────────────────────────────────────────────
+ // A space may carry the task of the person's agent that builds it. The agent works through the builder API
+ // (server/spaces/agent-api.js) as the person, and the task is theirs: Space asked Actor for it with the
+ // person in X-OV-Subject.
+
+ /** The person's own space by id. Another person's space is the same 404 as one that does not exist. */
+ async function ownedOf(person, id) {
+ const space = isSpaceId(id) ? (await q.byId.get(id)) || null : null;
+ if (!space || space.owner_subject !== person) return null;
+ return space;
+ }
+
+ /** The person's own spaces, newest first. */
+ async function mineOf(person) {
+ if (!person) return [];
+ return await q.mine.all(person);
+ }
+
+ /** The viewer of a person: what Space's own write paths expect (the owner of a space). */
+ function viewerOf(person, space) {
+ return {
+ kind: 'user',
+ subject: person,
+ staff: false,
+ user: { username: (space && space.owner_username) || '', name: (space && space.owner_name) || '' },
+ };
+ }
+
+ /** The task of the person's agent that builds this space, and what it is doing. */
+ async function setAgentTask(space, taskId, state) {
+ await q.setAgentTask.run(String(taskId || ''), String(state || 'queued'), store.now(), space.id);
+ return await get(space.id);
+ }
+ async function setAgentState(space, state) {
+ await q.setAgentState.run(String(state || ''), store.now(), space.id);
+ return await get(space.id);
+ }
+ async function setAgentStep(space, stepId) {
+ await q.setAgentStep.run(String(stepId || ''), store.now(), space.id);
+ return await get(space.id);
+ }
+ /** The spaces whose agent task is still running: the watcher follows them. */
+ async function agentRunning() { return await q.agentTasks.all(); }
+
+ /**
+ * The agent asks to publish a space. Nothing goes live: the request waits for the person, who answers it in
+ * the Dock ("Needs you") or on the space's page. A request that is already waiting is answered as it is.
+ */
+ async function askPublish(person, space) {
+ const pending = await q.publishPending.get(space.id);
+ if (pending) return { id: pending.id, state: 'pending', url: null, reason: null };
+ const id = `apr_${ids.ulid(store.now())}`;
+ await q.addPublish.run(id, space.id, person, store.now());
+ await q.setAgentPublish.run(id, store.now(), space.id);
+ return { id, state: 'pending', url: null, reason: null };
+ }
+
+ /** The publish request the person is answering, when it is theirs. */
+ async function publishRequest(person, id) {
+ const row = await q.publishById.get(String(id || ''));
+ if (!row || row.owner_subject !== person) return null;
+ return row;
+ }
+
+ /**
+ * The person's answer to a publish request: approved publishes the space now, declined leaves it as it is.
+ * Answering twice is the answer that stands.
+ */
+ async function decidePublish(person, id, decision) {
+ const row = await q.publishById.get(String(id || ''));
+ if (!row || row.owner_subject !== person) return { error: 'No such request of yours.', status: 404 };
+ if (row.state !== 'pending') return { state: row.state, space: await get(row.space_id) };
+ const now = store.now();
+ if (decision === 'decline') {
+ await q.decidePublish.run('declined', now, row.id);
+ return { state: 'declined', space: await get(row.space_id) };
+ }
+ const space = await get(row.space_id);
+ try {
+ const out = await publish(viewerOf(person, space), space);
+ await q.decidePublish.run('approved', now, row.id);
+ return { state: 'approved', space: out.space, url: out.url };
+ } catch (err) {
+ // The person said yes, but the space could not go live (public hosting is not open yet, most of all).
+ // The request remembers the answer and why it did not work.
+ await q.decidePublish.run('failed', now, row.id);
+ return { state: 'failed', space, reason: (err && err.message) || 'the space could not be published' };
+ }
+ }
+
// ── Publishing: the hosting engine serves a published space ──
// ── Publishing: the hosting engine serves a published space ──
const canPublish = (viewer) => Boolean(config.publicHosting || (viewer && viewer.staff));
const canPublish = (viewer) => Boolean(config.publicHosting || (viewer && viewer.staff));
@@ -418,6 +519,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -418,6 +519,9 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
CAPABILITIES, isSpaceId, get, getBySlug, forSite, load, isOwner, files, create, saveFiles, updateSettings, fork,
startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,
startBuild, finishBuild, failBuild, spend, used, canPublish, publish, unpublish, publishedFiles, remove, gallery, mine, hostnameOf, urlOf,
personOf, capsOf, checkFiles, DAY,
personOf, capsOf, checkFiles, DAY,
+ // The person's one agent (plan 2026-10-22 §Z stage Z1)
+ ownedOf, mineOf, viewerOf, setAgentTask, setAgentState, setAgentStep, agentRunning,
+ askPublish, publishRequest, decidePublish,
// Guest drafts (openvibe.website, plan 5.9)
// Guest drafts (openvibe.website, plan 5.9)
isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,
isGuest, guestExpired, guestKeyOk, touchGuest, createGuest, claimGuest, spendGuestDraft, spendGuestAi,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+/**
+ * Space → the agent's one timeline (plan 2026-10-22 §Z stage Z1).
+ *
+ * Every step of the person's agent that builds a space is published into the network's agent namespace
+ * (agent.step.started | updated | ended, contracts common.agent-step@1), with the person as its subject: OpenVibe.Actor
+ * keeps the one timeline behind the Dock, and the person's Dock shows the step while Space shows the space's live
+ * preview. A step carries a title, an outcome and a link: never a prompt, a diff or a page.
+ *
+ * The publish the agent asks for is a step of its own, waiting for the person ("Needs you" in the Dock): the person
+ * answers it there, and the answer lands in Space.
+ */
+const contracts = require('openvibe-contracts');
+
+const MODE = 'act'; // the agent acts on the person's space
+const PRODUCT = 'space';
+
+function createSpaceSteps({ outbox, config = {}, log = console }) {
+ const enabled = Boolean(outbox && typeof outbox.emit === 'function');
+
+ /** One step into the timeline. Nothing is published when the payload does not fit the contract. */
+ async function emit(type, { space, person, stepId, status, title, link, reason, approval, endedAt, startedAt }) {
+ if (!enabled) return null;
+ const payload = {
+ step_id: String(stepId || '').slice(0, 80),
+ mode: MODE,
+ product: PRODUCT,
+ ref: String(stepId || '').slice(0, 120),
+ title: String(title || 'A step of the agent').slice(0, 160),
+ status,
+ started_at: startedAt || new Date().toISOString(),
+ ended_at: endedAt || null,
+ link: link || null,
+ reason: reason ? String(reason).slice(0, 240) : null,
+ approval: approval || null,
+ };
+ const v = contracts.validate('common.agent-step@1', payload);
+ if (!v.valid) {
+ log.warn('[Spaces steps] the step does not fit the contract:', (v.errors || []).map((e) => e.message).join('; '));
+ return null;
+ }
+ try {
+ await outbox.emit({
+ event_type: `agent.step.${type}`,
+ actor: { type: 'user', id: person },
+ subject: { type: 'user', id: person },
+ visibility: 'internal',
+ payload,
+ });
+ outbox.kick();
+ } catch (err) {
+ log.warn('[Spaces steps] the step could not be published:', (err && err.message) || err);
+ }
+ return payload;
+ }
+
+ const editorOf = (space) => `${config.baseUrl || 'https://openvibe.space'}/spaces/${encodeURIComponent(space.slug)}/edit`;
+
+ return {
+ enabled,
+ /** The agent started building this space. */
+ async started({ space, person, taskId, startedAt }) {
+ return await emit('started', {
+ space, person, stepId: taskId, status: 'running',
+ title: `Building ${space.name}`,
+ link: editorOf(space), startedAt,
+ });
+ },
+ /** The agent wrote the space's files: the same step, brought forward. */
+ async working({ space, person, taskId }) {
+ return await emit('updated', {
+ space, person, stepId: taskId, status: 'running',
+ title: `Building ${space.name}`,
+ link: editorOf(space),
+ });
+ },
+ /** The task ended. */
+ async ended({ space, person, taskId, ok, reason, endedAt }) {
+ return await emit('ended', {
+ space, person, stepId: taskId, status: ok ? 'done' : 'failed',
+ title: ok ? `Built ${space.name}` : `Could not build ${space.name}`,
+ link: editorOf(space), reason: ok ? null : reason, endedAt,
+ });
+ },
+ /** The agent asks to publish: a step of its own that waits for the person. */
+ async publishAsked({ space, person, request }) {
+ return await emit('started', {
+ space, person, stepId: request.id, status: 'needs_you',
+ title: `Publish ${space.name}?`,
+ link: editorOf(space),
+ reason: 'Your agent wants to publish this space. Nothing goes live until you say yes.',
+ approval: { product: PRODUCT, id: request.id, kind: 'space.publish' },
+ });
+ },
+ /** The person answered the publish request. */
+ async publishAnswered({ space, person, request, approved, reason }) {
+ return await emit('ended', {
+ space, person, stepId: request.id, status: approved ? 'done' : 'failed',
+ title: approved ? `Published ${space.name}` : `Did not publish ${space.name}`,
+ link: editorOf(space), reason: approved ? null : reason, endedAt: new Date().toISOString(),
+ });
+ },
+ };
+}
+
+module.exports = { createSpaceSteps, MODE, PRODUCT };
@@ -44,8 +44,16 @@ const { EVENT_TYPES } = require('../server/events/outbox');
@@ -44,8 +44,16 @@ const { EVENT_TYPES } = require('../server/events/outbox');
await check('the proposed service manifest declares every event the service emits', async () => {
await check('the proposed service manifest declares every event the service emits', async () => {
assert.strictEqual(manifest.id, 'space');
assert.strictEqual(manifest.id, 'space');
const pinned = new Set(contracts.catalog.map((c) => c.id));
const pinned = new Set(contracts.catalog.map((c) => c.id));
+ // The agent.* namespace belongs to the network (like provider.* and share.*): a first-party mode publishes
+ // its own steps into it, and OpenVibe.Actor keeps the one timeline behind the Dock. One producer per event
+ // type is a manifest rule, so Space's manifest does not list them (OpenVibe.Run does the same).
+ const SHARED = ['agent.', 'provider.', 'share.'];
for (const e of EVENT_TYPES) {
for (const e of EVENT_TYPES) {
if (manifest.eventsProduced.includes(e)) continue;
if (manifest.eventsProduced.includes(e)) continue;
+ if (SHARED.some((p) => e.startsWith(p))) {
+ assert.ok(pinned.has(e), `${e} is a shared-namespace type the pinned catalogue knows`);
+ continue;
+ }
// An event the pinned openvibe-contracts does not know yet: it waits for its contracts PR and for
// An event the pinned openvibe-contracts does not know yet: it waits for its contracts PR and for
// the pin that follows it (space.app.claimed, plan 5.9). An event neither knows is a mistake.
// the pin that follows it (space.app.claimed, plan 5.9). An event neither knows is a mistake.
assert.ok(!pinned.has(e), `${e} is in neither the service manifest nor the pinned catalogue`);
assert.ok(!pinned.has(e), `${e} is in neither the service manifest nor the pinned catalogue`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+/**
+ * The builder as the person's own agent (plan 2026-10-22 §Z, stage Z1).
+ *
+ * "Describe it and OpenVibe AI builds it" starts a task of the person's one agent (OpenVibe.Actor): the space
+ * is the task's subject, the agent works on it through Space's builder API, and the page shows the space as the
+ * agent writes it. Publishing waits for the person: the agent asks, the person answers.
+ *
+ * The tests here are Space's side of it: the task is asked for with the person named, the builder API reaches
+ * only that person's own spaces, the page carries the task and follows it, and nothing goes live before the
+ * person says so.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+const { csrfToken } = require('../server/auth/forms');
+
+const ORIGIN = 'https://openvibe.space';
+const TASK = 'tsk_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const BUILD_CAPS = ['space.build.read', 'space.build.create', 'space.build.write', 'space.build.publish'];
+
+(async () => {
+ const actorCalls = [];
+ let taskState = 'running';
+ // The fake OpenVibe.Actor: it takes the task Space asks for and answers how it is doing. Everything else
+ // (the token for the service) goes to the network the test runs.
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(url);
+ if (u.host !== 'actor.test') return await fetch(url, init);
+ const headers = init.headers || {};
+ actorCalls.push({
+ method: init.method || 'GET', path: u.pathname, subject: headers['X-OV-Subject'],
+ auth: headers.Authorization || null, body: init.body ? JSON.parse(init.body) : null,
+ });
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+ if (init.method === 'POST') return json(201, { id: TASK, state: 'queued' });
+ return json(200, {
+ id: TASK, state: taskState, task: 'build it',
+ result: taskState === 'succeeded' ? { answer: 'A little quiz about the nineties.' } : null,
+ });
+ };
+
+ const t = await boot({ env: { SPACE_BUILDER: 'agent', OV_ACTOR_INTERNAL_URL: 'http://actor.test' }, fetchImpl });
+ const alice = t.user('alice'), bob = t.user('bob');
+ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
+ const form = (u, p, fields) => t.api('POST', p, { session: u, form: { csrf: csrf(u), ...fields }, headers: { origin: ORIGIN } });
+ const agent = (method, p, person, json) => t.api(method, p, { as: t.network.serviceToken('actor', BUILD_CAPS), json, headers: { 'x-ov-subject': person.subject } });
+
+ let slug = null;
+ let spaceId = null;
+
+ await check('the Describe it box starts a task of the person\'s own agent', async () => {
+ const r = await form(alice, '/spaces/new', { prompt: 'A trivia quiz about 90s cartoons with a leaderboard' });
+ assert.strictEqual(r.status, 303, r.text);
+ slug = String(r.headers.location || '').split('/').slice(-2)[0];
+ assert.ok(slug, 'the space was made');
+ const call = actorCalls.find((c) => c.method === 'POST' && c.path === '/api/v1/tasks');
+ assert.ok(call, 'the agent was asked for a task');
+ assert.strictEqual(call.subject, alice.subject, 'the task is the person\'s');
+ assert.ok(/X-OV-Subject|subject/.test(JSON.stringify(call)), 'the person is named for the agent');
+ assert.match(call.body.task, /space/i, 'the task says what to build');
+ assert.match(call.body.task, /spc_[0-9A-HJKMNP-TV-Z]{26}/, 'the task names the space it works on');
+ });
+
+ await check('the editor carries the task, so the Dock opens at it', async () => {
+ const page = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes(TASK), 'the page carries the task id');
+ const boot = JSON.parse(page.text.match(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/)[1].replace(/\\u003c/g, '<'));
+ assert.strictEqual(boot.agent.task, TASK);
+ assert.strictEqual(boot.agent.state, 'running');
+ spaceId = boot.space.id;
+ // The page opens the Dock at that task: the shared navbar's API (the Dock's Agent panel marks the step).
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'space-editor.js'), 'utf8');
+ assert.ok(/OpenVibeNavbar\.openDock\('agent', \{ task: agent\.task \}\)/.test(js), 'the editor opens the Dock at the task');
+ });
+
+ await check('the agent works on the space through the builder API, and the page shows it', async () => {
+ assert.ok(spaceId, 'the space has an id');
+ const write = await agent('PUT', `/api/v1/builder/spaces/${spaceId}/files`, alice, { files: [{ path: 'index.html', content: '<h1>90s cartoons</h1>' }] });
+ assert.strictEqual(write.status, 200, write.text);
+ assert.strictEqual(write.json().files[0].path, 'index.html');
+ // The page follows the task and shows the space as the agent wrote it.
+ const follow = await t.api('GET', `/spaces/${slug}/agent`, { session: alice, headers: { origin: ORIGIN, 'x-csrf-token': csrf(alice) } });
+ assert.strictEqual(follow.status, 200, follow.text);
+ assert.strictEqual(follow.json().task, TASK);
+ assert.strictEqual(follow.json().status, 'running');
+ assert.strictEqual(follow.json().files[0].content, '<h1>90s cartoons</h1>', 'the page shows what the agent wrote');
+ });
+
+ await check('the editor\'s own change box starts a task of the agent too', async () => {
+ const r = await t.api('POST', `/spaces/${slug}/ai`, { session: alice, json: { prompt: 'make the buttons bigger' }, headers: { origin: ORIGIN, 'x-csrf-token': csrf(alice) } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.json().task, 'the answer names the task of the agent');
+ assert.strictEqual(r.json().building, true);
+ const call = actorCalls.filter((c) => c.method === 'POST' && c.path === '/api/v1/tasks').pop();
+ assert.strictEqual(call.subject, alice.subject);
+ assert.match(call.body.task, /make the buttons bigger/, 'the agent is told what to change');
+ });
+
+ await check('another person\'s space is refused: the same 404 as one that does not exist', async () => {
+ const r = await agent('GET', `/api/v1/builder/spaces/${spaceId}`, bob, null);
+ assert.strictEqual(r.status, 404, r.text);
+ const files = await agent('GET', `/api/v1/builder/spaces/${spaceId}/files`, bob, null);
+ assert.strictEqual(files.status, 404, files.text);
+ });
+
+ await check('publishing waits for the person: nothing goes live before they answer', async () => {
+ const ask = await agent('POST', `/api/v1/builder/spaces/${spaceId}/publish`, alice, {});
+ assert.strictEqual(ask.status, 200, ask.text);
+ assert.strictEqual(ask.json().state, 'pending');
+ const approval = ask.json().approval_id;
+ assert.ok(approval, 'the request the person answers has an id');
+ // Still a draft while it waits.
+ const page = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });
+ assert.ok(!/"published":true/.test(page.text), 'nothing is live yet');
+
+ const answer = await t.api('POST', `/api/v1/agent/approvals/${approval}/decide`, { as: alice, json: { decision: 'approve' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(answer.status, 200, answer.text);
+ assert.strictEqual(answer.json().state, 'approved');
+ const after = await t.api('GET', `/spaces/${slug}/edit`, { session: alice });
+ assert.ok(/"published":true/.test(after.text), 'the space is live after the answer');
+ });
+
+ await check('the step the agent takes reaches the network\'s agent namespace', async () => {
+ const steps = await t.events('agent.step.started');
+ assert.ok(steps.length, 'the step was published');
+ const step = steps[0];
+ assert.strictEqual(step.source, 'space');
+ assert.strictEqual(step.subject.id, alice.subject, 'the step is the person\'s');
+ assert.strictEqual(step.payload.mode, 'act');
+ assert.strictEqual(step.payload.product, 'space');
+ assert.strictEqual(step.payload.step_id, TASK);
+ });
+
+ await check('while public hosting is off, the agent is told at once that a space cannot go live', async () => {
+ const t2 = await boot({ env: { SPACE_BUILDER: 'agent', OV_ACTOR_INTERNAL_URL: 'http://actor.test', SPACE_PUBLIC_HOSTING: 'off' }, fetchImpl });
+ try {
+ const carol = t2.user('carol');
+ const csrf2 = (u) => csrfToken(t2.config, { subject: u.subject });
+ const made = await t2.api('POST', '/spaces/new', { session: carol, form: { csrf: csrf2(carol), prompt: 'A small diary for my notes' }, headers: { origin: ORIGIN } });
+ assert.strictEqual(made.status, 303, made.text);
+ const slug2 = String(made.headers.location || '').split('/').slice(-2)[0];
+ const page = await t2.api('GET', `/spaces/${slug2}/edit`, { session: carol });
+ const id2 = JSON.parse(page.text.match(/<script type="application\/json" id="sp-boot">([\s\S]*?)<\/script>/)[1].replace(/\\u003c/g, '<')).space.id;
+ const ask = await t2.api('POST', `/api/v1/builder/spaces/${id2}/publish`, {
+ as: t2.network.serviceToken('actor', BUILD_CAPS), json: {}, headers: { 'x-ov-subject': carol.subject },
+ });
+ assert.strictEqual(ask.status, 403, ask.text);
+ assert.match(ask.text, /public hosting/, 'the reason says why');
+ } finally {
+ await t2.close();
+ }
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });