openvibers/openvibe.codes Commits

CODES-5: Forgejo client, guided Improve OpenVibe, the user task API, Actor fields (#145)

OP OpenVibers committed 16 days ago · 45cbbc2a69 · 17 files, +797 −28

Changes

ddiff --git a/README.md b/README.md
iindex ad50096..07503fd 100644
---- a/README.md
++++ b/README.md
@@@ -72,7 +72,7 @@ openvibe-codes resume <run id> "now add a test"
- The harness catalog and the routing policy over it (placement itself is `openvibe-sdk/placement`, the one implementation every service uses).
- Its pages and the community documents in this repository.
- The harness package (`harness/`, `bin/`): adapters, the event format, hand-offs and the local run store.
--- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; hosted runs will live here.
++- Its database holds the runs people start on the site or through the API (`run_tasks`), their streamed session lines (`codes_session_lines`), inbound Run events and the `codes.task.*` outbox. The console's tables were dropped by migration 0002 when it moved.
## Does not own
ddiff --git a/STATUS.json b/STATUS.json
iindex 1048c95..f4885f8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -27,7 +27,7 @@
"The agent catalog (server/data/harness-offers.json, validated at boot as platform.harness-offer@1 / platform.agent-offer@1): /harnesses and GET /api/v1/harnesses",
"Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates } (the same router the command line uses)",
"Getting started (/start): install, the agents and what each needs, commands, permission levels, hand-offs, the event format, using it from code",
-- "Improve OpenVibe (/improve): every repository from the pinned contracts' service manifests, and the contributor path",
++ "Improve OpenVibe (/improve): a guided run from repository choice to a task on your machine, plus the contributor path",
"The community documents (code of conduct, contributing, contributor ladder, moderation) rendered from this repository",
"Permanent redirects for every former console address (301 for reads, 308 for writes) to openvibe.services",
"robots.txt, sitemap.xml, llms.txt and llms-full.txt; per-actor and per-address limits; truthful readiness"
@@@ -35,7 +35,6 @@
"notYet": [
"A hosted runner (tasks on OpenVibe.Run workers for people who do not run agents themselves) with budgets and metering",
"OpenVibe.Actor using Codes for its coding work",
-- "Improve OpenVibe as a guided run on this site (repository → change → tests → review → pull request); today it is the harness plus the contributor path on GitHub",
"The OpenVibe agent pool (~/openvibe/agents) running on this package instead of its own adapters",
"The community documents are drafts pending the owner's review"
],
@@@ -50,7 +49,8 @@
"Improve OpenVibe",
"community documents",
"redirects for the moved developer console",
-- "run a coding task on your own machine from any browser (/run; openvibe-codes job through OpenVibe Run and the person's own OpenVibe Node)"
++ "run a coding task on your own machine from any browser (/run; openvibe-codes job through OpenVibe Run and the person's own OpenVibe Node)",
++ "user JSON API for signed-in people to list, start, read and cancel their own tasks"
],
"updated": "2026-10-28"
}
ddiff --git a/migrations/0009_actor_ref.sql b/migrations/0009_actor_ref.sql
nnew file mode 100644
iindex 0000000..13b82d6
---- /dev/null
++++ b/migrations/0009_actor_ref.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- CODES-5: Actor back-reference. A first-party service (OpenVibe.Actor) may pass its own task/step ids when it
++-- starts a coding task for a person; Codes stores them but does not expose them in codes.task@1 or the events
++-- (those need a contract amendment). The column is jsonb so the shape can settle before it is promoted.
++ALTER TABLE run_tasks ADD COLUMN IF NOT EXISTS actor_ref jsonb;
++CREATE INDEX IF NOT EXISTS run_tasks_actor_ref_task ON run_tasks ((actor_ref ->> 'task_id')) WHERE actor_ref IS NOT NULL;
ddiff --git a/public/js/run.js b/public/js/run.js
iindex 628406b..52d8298 100644
---- a/public/js/run.js
++++ b/public/js/run.js
@@@ -65,13 +65,27 @@
source.addEventListener('state', function (e) {
try {
var s = JSON.parse(e.data).state;
++ if (LABEL[s]) lastState = s;
if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
if (view) view.status(s, LABEL[s]);
} catch (err) { /* ignore */ }
});
++ var lastState = section.getAttribute('data-state') || 'queued';
source.addEventListener('end', function () {
source.close();
-- setTimeout(function () { window.location.reload(); }, 800);
++ // Switch from live streaming to replay without a full reload: the session lines are read back from
++ // /run/tasks/:id/session.json and pushed into the same agent-session view.
++ if (view && window.fetch) {
++ var sessionUrl = section.getAttribute('data-events').replace('/events', '/session.json') + '?after=0';
++ fetch(sessionUrl, { credentials: 'same-origin' })
++ .then(function (r) { return r.json(); })
++ .then(function (data) {
++ if (!data || !Array.isArray(data.lines)) return;
++ view.pushAll(data.lines.map(function (l) { return l.body; }));
++ view.status(lastState, LABEL[lastState] || lastState);
++ })
++ .catch(function () { /* leave the streamed lines as they are */ });
++ }
});
source.onerror = function () {
// The stream ended or could not open (Run busy or down): the page itself shows where the task is, so it is
ddiff --git a/server/app.js b/server/app.js
iindex 9f9aec6..4c133e1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -36,9 +36,11 @@ const { createSessionLines } = require('./domain/session-lines');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
++const { createMeApi } = require('./http/me-api');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
++const { createForgejoClient } = require('./domain/forgejo');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -66,6 +68,7 @@ async function createApp(opts = {}) {
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox });
const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks };
++ ctx.forgejo = createForgejoClient({ config, log });
const app = express();
app.disable('x-powered-by');
@@@ -166,6 +169,7 @@ async function createApp(opts = {}) {
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
++ app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/domain/forgejo.js b/server/domain/forgejo.js
nnew file mode 100644
iindex 0000000..c0f2187
---- /dev/null
++++ b/server/domain/forgejo.js
@@@ -0,0 +1,133 @@
++'use strict';
++
++/**
++ * OpenVibe Git (Forgejo) client for the server side (plan 2026-10-28 §6, decision 7):
++ * branches, pull requests, reviews and webhooks on the OpenVibe forge. The internal address is read from
++ * config.git.internalUrl; Forgejo listens on 127.0.0.1:5060, a port the Fetch standard blocks, so this client uses
++ * node:http / node:https. A caller without a token gets `forgejo.unconfigured`; a 4xx/5xx becomes a plain Error with
++ * the Forgejo message and a `status` property.
++ *
++ * createForgejoClient({ config, fetchImpl?, log? })
++ * getRepo(owner, name)
++ * listBranches(owner, name)
++ * createBranch(owner, name, { base_branch, new_branch_name })
++ * listPullRequests(owner, name, { state })
++ * createPullRequest(owner, name, { base, head, title, body })
++ * getPullRequest(owner, name, index)
++ * listReviews(owner, name, index)
++ * createWebhook(owner, name, { type, config, events })
++ * deleteWebhook(owner, name, id)
++ */
++
++const http = require('http');
++const https = require('https');
++
++function slug(s) {
++ return String(s || '').replace(/^\/+|\/+$/g, '');
++}
++
++function requestJson(base, token, method, path, body, { timeoutMs = 15000 } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(`${base.replace(/\/+$/, '')}/${slug(path)}`);
++ const lib = u.protocol === 'https:' ? https : http;
++ const payload = body == null ? null : JSON.stringify(body);
++ const req = lib.request(u, {
++ method,
++ headers: {
++ Accept: 'application/json',
++ ...(token ? { Authorization: `token ${token}` } : {}),
++ ...(payload ? { 'Content-Type': 'application/json' } : {}),
++ },
++ timeout: timeoutMs,
++ }, (res) => {
++ let raw = '';
++ res.setEncoding('utf8');
++ res.on('data', (c) => { raw += c; if (raw.length > 4_000_000) req.destroy(new Error('response too large')); });
++ res.on('end', () => {
++ let parsed = null;
++ if (raw.trim()) {
++ try { parsed = JSON.parse(raw); } catch (e) { parsed = { message: raw.slice(0, 500) }; }
++ }
++ if (res.statusCode >= 400) {
++ const err = new Error(parsed && parsed.message ? String(parsed.message) : `Forgejo ${res.statusCode}`);
++ err.status = res.statusCode;
++ err.body = parsed;
++ return reject(err);
++ }
++ resolve({ status: res.statusCode, body: parsed });
++ });
++ });
++ req.on('timeout', () => req.destroy(new Error('timeout')));
++ req.on('error', reject);
++ if (payload) req.write(payload);
++ req.end();
++ });
++}
++
++function createForgejoClient({ config, log = console }) {
++ const base = String((config && config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');
++ const publicUrl = String((config && config.git && config.git.publicUrl) || 'https://git.openvibe.codes').replace(/\/+$/, '');
++ const token = (process.env.CODES_GIT_TOKEN || process.env.FORGEJO_TOKEN || '').trim();
++
++ function configured() {
++ if (!token) return { ok: false, code: 'forgejo.unconfigured', detail: 'CODES_GIT_TOKEN is not set' };
++ return { ok: true };
++ }
++
++ function call(method, path, body) {
++ const check = configured();
++ if (!check.ok) {
++ const err = new Error(check.detail);
++ err.code = check.code;
++ err.status = 503;
++ return Promise.reject(err);
++ }
++ return requestJson(base, token, method, `api/v1/${path}`, body);
++ }
++
++ const repoPath = (owner, name) => `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
++
++ return {
++ baseUrl: base,
++ publicUrl,
++ configured,
++ async getRepo(owner, name) {
++ const { body } = await call('GET', `${repoPath(owner, name)}`);
++ return body;
++ },
++ async listBranches(owner, name) {
++ const { body } = await call('GET', `${repoPath(owner, name)}/branches`);
++ return Array.isArray(body) ? body : [];
++ },
++ async createBranch(owner, name, { base_branch, new_branch_name }) {
++ const { body } = await call('POST', `${repoPath(owner, name)}/branches`, { base_branch, new_branch_name });
++ return body;
++ },
++ async listPullRequests(owner, name, { state = 'open' } = {}) {
++ const { body } = await call('GET', `${repoPath(owner, name)}/pulls?state=${encodeURIComponent(state)}`);
++ return Array.isArray(body) ? body : [];
++ },
++ async createPullRequest(owner, name, { base, head, title, body: prBody }) {
++ const { body } = await call('POST', `${repoPath(owner, name)}/pulls`, { base, head, title, body: prBody });
++ return body;
++ },
++ async getPullRequest(owner, name, index) {
++ const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}`);
++ return body;
++ },
++ async listReviews(owner, name, index) {
++ const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}/reviews`);
++ return Array.isArray(body) ? body : [];
++ },
++ async createWebhook(owner, name, { type = 'forgejo', config, events = ['push', 'pull_request'] }) {
++ const { body } = await call('POST', `${repoPath(owner, name)}/hooks`, { type, config, events });
++ return body;
++ },
++ async deleteWebhook(owner, name, id) {
++ const { body } = await call('DELETE', `${repoPath(owner, name)}/hooks/${encodeURIComponent(id)}`);
++ return body;
++ },
++ };
++}
++
++module.exports = { createForgejoClient, requestJson };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 17f3a54..b8bc16b 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -128,13 +128,14 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
}
-- async function start({ subject, project, args, via = null, key = null }) {
++ async function start({ subject, project, args, via = null, key = null, actorRef = null }) {
const id = store.newId('tsk');
const at = store.now();
++ const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
await store.tx(async () => {
-- const inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12, $12) RETURNING *`,
-- [id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, at]);
++ const inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $13) RETURNING *`,
++ [id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, actorRefValue, at]);
await taskOutbox.emitTask(inserted, null);
return inserted;
});
ddiff --git a/server/http/me-api.js b/server/http/me-api.js
nnew file mode 100644
iindex 0000000..0b4e955
---- /dev/null
++++ b/server/http/me-api.js
@@@ -0,0 +1,125 @@
++'use strict';
++
++/**
++ * User JSON API for coding tasks (plan 2026-10-28 §6, decision 8): a signed-in person can list, start, read and cancel
++ * their own tasks without a service token. POST /api/v1/me/tasks creates a project first when the person has none they
++ * may run jobs in, so a first task can be started from code. Calls count against the person's own per-actor budget.
++ *
++ * GET /me/tasks → { tasks: [ codes.task@1, … ] }
++ * POST /me/tasks → { task: codes.task@1 } (201)
++ * GET /me/tasks/:id → codes.task@1
++ * POST /me/tasks/:id/cancel → codes.task@1
++ */
++const express = require('express');
++const { http, validate } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { parseArgs } = require('../../harness/job');
++const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
++
++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DELEGABLE = new Set(['owner', 'admin']);
++
++function createMeApi({ config, store, sso, run, harnesses, actorLimits, tasks = null, log = console }) {
++ const r = asyncRouter();
++ const db = store.db;
++ const taskStore = tasks || createRunTasks({ store, run, config, log });
++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const json = express.json({ limit: '32kb' });
++
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ };
++
++ /** The person's projects they may run jobs in (owner or admin, not archived), from Network with their own token. */
++ async function projectsOf(req, res) {
++ const list = await sso.asViewer(req, res, async (token) => {
++ const out = await fetch(`${config.networkInternalUrl}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status} for the projects`);
++ return (await out.json()).projects || [];
++ });
++ return list.filter((p) => DELEGABLE.has(p.role) && !p.archived_at).map((p) => ({ id: p.id, name: p.name }));
++ }
++
++ async function ensureProject(req, res) {
++ const projects = await projectsOf(req, res);
++ if (projects.length) return projects[0].id;
++ const created = await sso.asViewer(req, res, async (token) => {
++ const out = await fetch(`${config.networkInternalUrl}/api/v1/projects`, {
++ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ name: 'My code' }), signal: AbortSignal.timeout(8000),
++ });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status}`);
++ return out.json();
++ });
++ return created.id;
++ }
++
++ const own = async (req, id) => {
++ if (!TASK.test(String(id))) return null;
++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
++ return row && row.subject === req.viewer.subject ? row : null;
++ };
++
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++
++ r.get('/me/tasks', actorLimits.reads('codes.read'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
++ const rows = await db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY updated_at DESC LIMIT 50', [req.viewer.subject]);
++ return res.json({ tasks: rows.map((row) => taskStore.present(row)) });
++ });
++
++ r.post('/me/tasks', actorLimits.budget('codes.task.start'), json, async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to start a task');
++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
++ const b = req.body || {};
++ const check = validate('codes.task-start-request@1', b);
++ if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
++ if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');
++ let args;
++ try {
++ args = parseArgs(JSON.stringify({
++ repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit',
++ ...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),
++ }));
++ } catch (e) {
++ return problem(req, res, 422, 'codes.task_invalid', e.message);
++ }
++ let project;
++ try {
++ project = await ensureProject(req, res);
++ } catch (e) {
++ return problem(req, res, e.status === 401 ? 401 : 502, 'project.unavailable', `Could not get or create a project (${e.message}).`);
++ }
++ const row = await taskStore.start({ subject: req.viewer.subject, project, args });
++ log.info(`[Codes] user ${req.viewer.subject} started task ${row.id} through the user API`);
++ return res.status(201).json({ task: taskStore.present(row), created: true });
++ });
++
++ r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
++ const row = await own(req, req.params.id);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of yours');
++ return res.json(taskStore.present(await taskStore.refresh(row)));
++ });
++
++ r.post('/me/tasks/:id/cancel', actorLimits.budget('codes.task.start'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to cancel your tasks');
++ let row = await own(req, req.params.id);
++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of yours');
++ if (row.job_id && !TERMINAL.has(row.state)) {
++ try { await run.cancel(row.job_id, taskStore.who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
++ row = await taskStore.refresh(row, { force: true });
++ }
++ return res.json(taskStore.present(row));
++ });
++
++ return r;
++}
++
++module.exports = { createMeApi };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex b8bcc58..1cfa8ee 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -17,9 +17,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
// The console's paths (and everything under them), and the platform policy pages that moved with it.
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
--// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*) and the Action
--// Center read (codes.actions.read).
--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions)(\/|$)/;
++// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the user task
++// API and the Action Center read (codes.actions.read).
++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 92a1b10..4e7321a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -13,6 +13,7 @@
*/
const fs = require('fs');
const path = require('path');
++const express = require('express');
const contracts = require('openvibe-contracts');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
@@@ -24,6 +25,7 @@ const { html, raw, table, notice } = require('../render/html');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const placement = require('../domain/harness-placement');
++const { parseArgs } = require('../../harness/job');
const home = require('./home');
const seo = require('openvibe-shared/seo');
@@@ -73,7 +75,7 @@ const FLAG_LABELS = { host_access: 'runs on your machine', mcp: 'MCP tools', lon
const flagsOf = (c) => Object.keys(FLAG_LABELS).filter((k) => c && c[k]).map((k) => FLAG_LABELS[k]);
function createPageRoutes(ctx) {
-- const { config, harnesses } = ctx;
++ const { config, harnesses, sso, store, run, log = console } = ctx;
const r = asyncRouter();
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const governance = loadGovernance();
@@@ -129,12 +131,12 @@ ${raw(home.gitSection((ctx.git || gitReader).peek()))}
<p><a class="sc-btn sc-primary" href="/improve">Start contributing</a> <a class="sc-btn" href="/policy/contributing">Read the guide</a></p></section>
<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
${table(['Piece', 'State'], [
-- [html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the seven agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
++ [html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
[html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.bot/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in the Node\'s job sandbox.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
-- [html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: by hand, or with the harness doing the change on your machine.'],
++ [html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
[html`<a href="${config.git.publicUrl}/">OpenVibe Git</a>: OpenVibe's own code forge`, 'Live: every public OpenVibe repository, mirrored from GitHub hourly, with OpenVibe sign-in and HTTPS cloning. GitHub stays canonical until a repository moves here.'],
])}</section>
${raw(home.faq())}
@@@ -264,24 +266,69 @@ ${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.lengt
});
// ── Improve OpenVibe ─────────────────────────────────────
++ const knownRepo = (owner, name) => {
++ const full = `${owner}/${name}`;
++ return contracts.services.manifests.find((m) => m && m.repository === `OpenVibers/${name}` && m.repository === `OpenVibers/${name}` ? full === m.repository : false) || null;
++ };
++ const repoMeta = () => contracts.services.manifests
++ .filter((m) => m && typeof m.repository === 'string' && /^OpenVibers\/[A-Za-z0-9._-]+$/.test(m.repository) && m.exposure && ['live', 'library'].includes(m.exposure.state));
++ const repoGroups = () => {
++ const rows = repoMeta();
++ const order = (m) => (m.site && m.site.position != null ? m.site.position : 50);
++ const running = rows.filter((m) => m.exposure.state === 'live').sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name));
++ const libraries = rows.filter((m) => m.exposure.state === 'library').sort((a, b) => a.name.localeCompare(b.name));
++ return { running, libraries };
++ };
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ };
++ const projectsOf = async (req, res) => {
++ const list = await sso.asViewer(req, res, async (token) => {
++ const out = await fetch(`${config.networkInternalUrl}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status}`);
++ return (await out.json()).projects || [];
++ });
++ const delegable = new Set(['owner', 'admin']);
++ return list.filter((p) => delegable.has(p.role) && !p.archived_at).map((p) => ({ id: p.id, name: p.name }));
++ };
++ const ensureProject = async (req, res) => {
++ const projects = await projectsOf(req, res);
++ if (projects.length) return projects[0].id;
++ const created = await sso.asViewer(req, res, async (token) => {
++ const out = await fetch(`${config.networkInternalUrl}/api/v1/projects`, {
++ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ name: 'My code' }), signal: AbortSignal.timeout(8000),
++ });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status}`);
++ return out.json();
++ });
++ return created.id;
++ };
++ const repoForgejoUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}`;
++ const repoCloneUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}.git`;
++
r.get('/improve', (req, res) => {
-- const { running, libraries } = repositories();
-- const repoCards = (list) => html`<ul class="cards">${list.map((m) => html`<li><a href="${GITHUB}/${m.repository.split('/')[1]}"><strong>${m.repository.split('/')[1]}</strong></a><span>${(m.site && (m.site.tagline || m.site.what)) || m.name}</span></li>`)}</ul>`;
++ const { running, libraries } = repoGroups();
++ const repoCards = (list) => html`<ul class="cards">${list.map((m) => html`<li><a href="/improve/${m.repository}"><strong>${m.repository.split('/')[1]}</strong></a><span>${(m.site && (m.site.tagline || m.site.what)) || m.name}</span></li>`)}</ul>`;
page(req, res, {
index: true, cache: PUBLIC_CACHE,
title: 'Improve OpenVibe',
-- description: 'OpenVibe is open source: every service and library is on GitHub. How to pick a repository, make a change, run its tests and open a pull request, and the community rules that apply.',
++ description: 'OpenVibe is open source: every service and library is on GitHub. Pick a repository, describe the change, and send a pull request; the harness makes the change on your machine.',
crumbs: [{ label: 'Improve OpenVibe' }],
body: html`<h1>Improve OpenVibe</h1>
--<p>Every OpenVibe service and library is open source under <a href="${GITHUB}">OpenVibers</a> on GitHub, and changes arrive as pull requests that the owner approves. Every change takes the same path: repository → change → tests → independent review → pull request, never straight to <code>main</code>. Make it by hand, or let <a href="/start">the harness</a> make it on your machine with the agent you choose; you open the pull request.</p>
++<p>Every OpenVibe service and library is open source under <a href="${GITHUB}">OpenVibers</a>, and changes arrive as pull requests that the owner approves. Every change takes the same path: repository → change → tests → independent review → pull request, never straight to <code>main</code>. Pick a repository, describe the change, and start a run on your own machine.</p>
<ol class="steps">
--<li><strong>Pick a repository</strong> from the lists below, or find the page you want to change and follow its "source" link.</li>
--<li><strong>Read its README</strong>: what it owns, how to run it, and its tests (<code>npm test</code> runs them on temporary databases; nothing needs a live service).</li>
--<li><strong>Make the change</strong> on a branch, with a test that would have failed before it.</li>
--<li><strong>Open a pull request</strong> that says what changes for people and why. CI runs the tests, an audit and a secret scan.</li>
--<li><strong>Review</strong>: a maintainer reviews it; the owner approves what ships. See the <a href="/policy/contributor-ladder">contributor ladder</a> for how reviewers and maintainers are chosen.</li>
++<li><strong>Pick a repository</strong> from the lists below.</li>
++<li><strong>Describe the change</strong> in plain language; the harness runs the tests your way.</li>
++<li><strong>Review the result</strong> on the task page, then open a pull request from the branch it pushed.</li>
++<li><strong>A maintainer reviews</strong>; the owner approves what ships. See the <a href="/policy/contributor-ladder">contributor ladder</a>.</li>
</ol>
--<p>Before you start: the <a href="/policy/contributing">contributing guide</a> and the <a href="/policy/code-of-conduct">code of conduct</a>. Building your own app on OpenVibe instead? That is <a href="${SERVICES_ORIGIN}">OpenVibe.Services</a>.</p>
++<p>Before you start: the <a href="/policy/contributing">contributing guide</a> and the <a href="/policy/code-of-conduct">code of conduct</a>. Building your own app? That is <a href="${SERVICES_ORIGIN}">OpenVibe.Services</a>.</p>
<h2>Services that run</h2>
${repoCards(running)}
<h2>Shared libraries</h2>
@@@ -289,6 +336,92 @@ ${repoCards(libraries)}`,
});
});
++ r.get('/improve/:owner/:name', async (req, res) => {
++ const { owner, name } = req.params;
++ const meta = repoMeta().find((m) => m.repository === `${owner}/${name}`);
++ if (!meta) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such OpenVibe repository. <a href="/improve">All repositories</a></p>` }, 404);
++ let forgejoInfo = null;
++ let forgejoError = null;
++ try { forgejoInfo = await ctx.forgejo.getRepo(owner, name); } catch (e) { forgejoError = e.code === 'forgejo.unconfigured' ? null : e.message; }
++ const agents = harnesses.list();
++ const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
++ const signed = signedIn(req);
++ const form = signed ? html`<form class="run-card run-form" method="post" action="/improve/start">
++<input type="hidden" name="repo" value="${repoForgejoUrl(owner, name)}">
++<input type="hidden" name="ref" value="${(forgejoInfo && forgejoInfo.default_branch) || 'main'}">
++<h2>Start a guided run</h2>
++${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVibe Git is not reachable right now (${forgejoError}); you can still start a run on your machine.</p>` : ''}
++<label class="run-field"><span>What should the agent change?</span><textarea name="prompt" rows="5" required maxlength="16000" placeholder="Add a test for the date parser and fix what it finds"></textarea></label>
++<div class="run-field-row">
++<label class="run-field"><span>Agent</span><select name="agent">${opt('', 'Any: the best fit, with hand-offs', true)}${agents.map((a) => opt(a.id, a.name))}</select></label>
++<label class="run-field"><span>It may</span><select name="permission">${opt('edit', 'Edit files', true)}${opt('full', 'Run anything (tests, installs)', false)}</select></label>
++</div>
++<label class="run-check"><input type="checkbox" name="push" value="1" checked> Push the change to a new branch on OpenVibe Git <small>(needs a token on your machine; never the branch it started from)</small></label>
++<label class="run-check"><input type="checkbox" name="pull_request" value="1" checked> and open a pull request</label>
++<button class="run-btn run-btn-primary" type="submit">Start on my machine</button>
++</form>` : html`<p><a class="run-btn run-btn-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to start a run</a></p>`;
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: `Improve ${name}`,
++ description: `Contribute to ${meta.name}: describe a change and run it on your machine through OpenVibe.Codes.`,
++ crumbs: [{ label: 'Improve OpenVibe', href: '/improve' }, { label: meta.name }],
++ body: html`<h1>${meta.name}</h1>
++<p class="lead">${(meta.site && (meta.site.tagline || meta.site.what)) || meta.name}. The source lives at <a href="${repoForgejoUrl(owner, name)}">${repoForgejoUrl(owner, name)}</a> and is mirrored from <a href="${GITHUB}/${name}">GitHub</a>.</p>
++<pre tabindex="0" class="codes-clone"><code>git clone ${repoCloneUrl(owner, name)}</code></pre>
++<h2>The path from a change to a pull request</h2>
++<ol class="steps">
++<li>The harness clones the repository onto <strong>your machine</strong> and checks out the default branch.</li>
++<li>The agent makes the change you described and runs the tests.</li>
++<li>If you asked it to push, it commits to a new branch and opens a pull request on OpenVibe Git.</li>
++<li>You review the result, then the maintainers review the pull request.</li>
++</ol>
++${form}`,
++ });
++ });
++
++ r.post('/improve/start', express.urlencoded({ extended: false, limit: '32kb' }), async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/improve')}`);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const b = req.body || {};
++ const repo = String(b.repo || '').trim();
++ const m = /^https:\/\/([^/]+)\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/?$/.exec(repo);
++ const meta = m && repoMeta().find((x) => x.repository === `${m[2]}/${m[3]}`);
++ if (!meta) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>Pick a repository from <a href="/improve">Improve OpenVibe</a>.</p>` }, 404);
++ const owner = m[2];
++ const name = m[3];
++ const prompt = String(b.prompt || '');
++ const agent = String(b.agent || '').trim();
++ const permission = b.permission === 'full' ? 'full' : 'edit';
++ const push = b.push === '1';
++ const pullRequest = push && b.pull_request === '1';
++ let args;
++ try {
++ if (agent && !harnesses.list().some((a) => a.id === agent)) throw new Error('choose an agent from the list');
++ args = parseArgs(JSON.stringify({
++ repo, ref: String(b.ref || '').trim() || null, prompt, agent: agent || null, permission,
++ ...(push ? { push: { branch: `openvibe-codes/improve-${Date.now().toString(36)}`, pull_request: pullRequest } } : {}),
++ }));
++ } catch (e) {
++ return page(req, res, {
++ title: `Improve ${name}`,
++ body: html`<h1>Improve ${name}</h1><p class="run-notice run-notice-error">Check the form: ${e.message}.</p><p><a href="/improve/${owner}/${name}">Back</a></p>`,
++ }, 422);
++ }
++ let project;
++ try {
++ project = await ensureProject(req, res);
++ } catch (e) {
++ return page(req, res, {
++ title: `Improve ${name}`,
++ body: html`<h1>Improve ${name}</h1><p class="run-notice run-notice-error">Could not get or create a project (${e.message}).</p><p><a href="/improve/${owner}/${name}">Back</a></p>`,
++ }, e.status === 401 ? 401 : 502);
++ }
++ const { createRunTasks } = require('../domain/run-tasks');
++ const taskStore = createRunTasks({ store, run, config, log });
++ const row = await taskStore.start({ subject: req.viewer.subject, project, args });
++ return res.redirect(303, `/run/tasks/${row.id}`);
++ });
++
// ── Community documents and the update log ───────────────
// What shipped on OpenVibe.Codes: the shared update log every OpenVibe site has.
r.get('/updates', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Codes', body: raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) }));
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 7d84ed8..10a3a5c 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -59,7 +59,15 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
const b = req.body;
-- const check = validate('codes.task-start-request@1', b);
++ // Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
++ // separately. When the contract is amended, pass them through instead.
++ const actorRef = {};
++ if (typeof b.actor_task_id === 'string') actorRef.task_id = b.actor_task_id;
++ if (typeof b.actor_step_id === 'string') actorRef.step_id = b.actor_step_id;
++ const bodyForValidate = { ...b };
++ delete bodyForValidate.actor_task_id;
++ delete bodyForValidate.actor_step_id;
++ const check = validate('codes.task-start-request@1', bodyForValidate);
if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');
const p = req.principal;
@@@ -83,7 +91,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
}
let row;
try {
-- row = await taskStore.start({ subject: p.subject, project, args, via: p.via, key });
++ row = await taskStore.start({ subject: p.subject, project, args, via: p.via, key, actorRef: Object.keys(actorRef).length ? actorRef : null });
} catch (e) {
// Two requests with one key at once: the unique index lets one in; the other answers with it.
const seen = key && e && e.code === '23505' ? await byKey(p, key) : null;
ddiff --git a/test/actor-fields.test.js b/test/actor-fields.test.js
nnew file mode 100644
iindex 0000000..ad2b0be
---- /dev/null
++++ b/test/actor-fields.test.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * Actor back-reference (CODES-5): when OpenVibe.Actor starts a coding task through codes.task.start it can pass its
++ * own task and step ids. The contract does not carry them yet, so they are stripped before validation, stored in
++ * run_tasks.actor_ref, and kept out of codes.task@1 until the contract is amended.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const run = {
++ enabled: true,
++ async submit(o) { return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
++ async read() { return { state: 'queued' }; },
++ async cancel() { return { state: 'cancelled' }; },
++ async streamUrl() { throw new Error('not used'); },
++ };
++ const t = await boot({ run });
++ const ada = t.network.addUser('ada');
++ const actor = t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.start', 'codes.task.read', 'codes.task.cancel']);
++
++ await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)
++ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, $2, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z200', 'https://github.com/a/b', 'by hand', 'succeeded', 1, 2)`, [ada.subject, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1']);
++
++ try {
++ await check('actor_task_id and actor_step_id are accepted, stored, and not returned in codes.task@1', async () => {
++ const res = await fetch(`${t.base}/api/v1/tasks`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject, 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ repo: 'https://github.com/OpenVibers/OpenVibe.Codes',
++ prompt: 'x',
++ actor_task_id: 'act_123',
++ actor_step_id: 'step_456',
++ }),
++ });
++ assert.strictEqual(res.status, 201);
++ const json = await res.json();
++ assert.ok(!('actor_task_id' in json.task), 'actor_task_id is not in the public task');
++ assert.ok(!('actor_step_id' in json.task));
++ const row = await t.ctx.store.db.one('SELECT actor_ref FROM run_tasks WHERE id = $1', [json.task.id]);
++ assert.deepStrictEqual(row.actor_ref, { task_id: 'act_123', step_id: 'step_456' });
++ });
++
++ await check('a body with only the contract fields still validates', async () => {
++ const res = await fetch(`${t.base}/api/v1/tasks`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${actor}`, 'X-OV-Subject': ada.subject, 'Content-Type': 'application/json', 'Idempotency-Key': 'actor-fields-2' },
++ body: JSON.stringify({ repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'y' }),
++ });
++ assert.strictEqual(res.status, 201);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/forgejo.test.js b/test/forgejo.test.js
nnew file mode 100644
iindex 0000000..99c9a66
---- /dev/null
++++ b/test/forgejo.test.js
@@@ -0,0 +1,110 @@
++'use strict';
++/**
++ * OpenVibe Git / Forgejo client (server/domain/forgejo.js): branches, pull requests, reviews and webhooks.
++ * The client uses node:http / node:https (not fetch) so it can reach Forgejo on 127.0.0.1:5060, and it refuses to do
++ * anything without a token. A stand-in Forgejo answers the same paths so no real forge is contacted.
++ */
++const assert = require('assert');
++const http = require('http');
++const { createForgejoClient } = require('../server/domain/forgejo');
++const { check, done } = require('./helpers/boot');
++
++function mockForgejo() {
++ const calls = [];
++ const repoBase = '/api/v1/repos/OpenVibers/OpenVibe.Codes';
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const auth = req.headers.authorization || '';
++ const body = raw ? JSON.parse(raw) : null;
++ const pathname = new URL(req.url, 'http://x').pathname;
++ calls.push({ method: req.method, path: req.url, auth, body });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ if (pathname === repoBase) {
++ return res.end(JSON.stringify({ full_name: 'OpenVibers/OpenVibe.Codes', default_branch: 'main' }));
++ }
++ if (pathname === `${repoBase}/branches` && req.method === 'POST') {
++ return res.end(JSON.stringify({ name: body.new_branch_name }));
++ }
++ if (pathname === `${repoBase}/branches`) {
++ return res.end(JSON.stringify([{ name: 'main' }, { name: 'feature' }]));
++ }
++ if (pathname === `${repoBase}/pulls` && req.method === 'POST') {
++ return res.end(JSON.stringify({ number: 5, title: body.title }));
++ }
++ if (pathname === `${repoBase}/pulls`) {
++ return res.end(JSON.stringify([{ number: 5, title: 'A PR' }]));
++ }
++ if (pathname === `${repoBase}/pulls/5`) {
++ return res.end(JSON.stringify({ number: 5, title: 'PR' }));
++ }
++ if (pathname === `${repoBase}/pulls/5/reviews`) {
++ return res.end(JSON.stringify([{ id: 1, state: 'APPROVED' }]));
++ }
++ if (pathname === `${repoBase}/hooks` && req.method === 'POST') {
++ return res.end(JSON.stringify({ id: 9 }));
++ }
++ if (pathname === `${repoBase}/hooks/9` && req.method === 'DELETE') {
++ return res.end('{}');
++ }
++ res.end('{}');
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, calls, url: `http://127.0.0.1:${server.address().port}` })));
++}
++
++(async () => {
++ const forgejo = await mockForgejo();
++ const old = process.env.FORGEJO_TOKEN;
++ process.env.FORGEJO_TOKEN = 'f-token';
++ const client = createForgejoClient({ config: { git: { internalUrl: forgejo.url, publicUrl: 'https://git.openvibe.codes' } }, log: { warn() {} } });
++
++ try {
++ await check('without a token the client is unconfigured', async () => {
++ delete process.env.FORGEJO_TOKEN;
++ delete process.env.CODES_GIT_TOKEN;
++ const noToken = createForgejoClient({ config: { git: { internalUrl: forgejo.url } }, log: { warn() {} } });
++ const c = noToken.configured();
++ assert.strictEqual(c.ok, false);
++ assert.strictEqual(c.code, 'forgejo.unconfigured');
++ await assert.rejects(noToken.getRepo('OpenVibers', 'OpenVibe.Codes'), (e) => e.code === 'forgejo.unconfigured');
++ process.env.FORGEJO_TOKEN = 'f-token';
++ });
++
++ await check('getRepo, listBranches and createBranch call the right Forgejo paths', async () => {
++ const repo = await client.getRepo('OpenVibers', 'OpenVibe.Codes');
++ assert.strictEqual(repo.default_branch, 'main');
++ const branches = await client.listBranches('OpenVibers', 'OpenVibe.Codes');
++ assert.strictEqual(branches.length, 2);
++ const created = await client.createBranch('OpenVibers', 'OpenVibe.Codes', { base_branch: 'main', new_branch_name: 'feature' });
++ assert.strictEqual(created.name, 'feature');
++ assert.ok(forgejo.calls.some((c) => c.method === 'GET' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));
++ assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));
++ assert.ok(forgejo.calls.some((c) => c.auth === 'token f-token'));
++ });
++
++ await check('pull requests and reviews are read and created', async () => {
++ const prs = await client.listPullRequests('OpenVibers', 'OpenVibe.Codes');
++ assert.strictEqual(prs[0].number, 5);
++ const pr = await client.getPullRequest('OpenVibers', 'OpenVibe.Codes', 5);
++ assert.strictEqual(pr.number, 5);
++ const created = await client.createPullRequest('OpenVibers', 'OpenVibe.Codes', { base: 'main', head: 'feature', title: 'Add test', body: 'Desc' });
++ assert.strictEqual(created.number, 5);
++ const reviews = await client.listReviews('OpenVibers', 'OpenVibe.Codes', 5);
++ assert.strictEqual(reviews[0].state, 'APPROVED');
++ });
++
++ await check('webhooks are created and deleted', async () => {
++ const hook = await client.createWebhook('OpenVibers', 'OpenVibe.Codes', { type: 'forgejo', config: { url: 'https://openvibe.codes/hooks' }, events: ['push'] });
++ assert.strictEqual(hook.id, 9);
++ await client.deleteWebhook('OpenVibers', 'OpenVibe.Codes', 9);
++ assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks'));
++ assert.ok(forgejo.calls.some((c) => c.method === 'DELETE' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks/9'));
++ });
++ } finally {
++ process.env.FORGEJO_TOKEN = old;
++ await new Promise((r) => forgejo.server.close(r));
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/improve.test.js b/test/improve.test.js
nnew file mode 100644
iindex 0000000..bda23a5
---- /dev/null
++++ b/test/improve.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Improve OpenVibe as a guided run (server/http/pages.js): the landing page lists every OpenVibe repository and links
++ * to a per-repo page; the repo page shows the contribution path and a form for signed-in users; posting it starts the
++ * same Run task as /run, in one of the person's projects (creating one if necessary), and redirects to the task page.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const calls = [];
++ const run = {
++ enabled: true,
++ async submit(o) { calls.push(['submit', o]); return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
++ async read() { return { state: 'queued' }; },
++ async cancel() { return { state: 'cancelled' }; },
++ async streamUrl() { throw new Error('not used'); },
++ };
++ const t = await boot({ run });
++ const ada = t.network.addUser('ada');
++ const post = (p, as, form, headers = {}) => t.get(p, { as, form, headers, method: 'POST' });
++
++ try {
++ await check('the Improve OpenVibe landing lists repositories from the contracts manifests', async () => {
++ const r = await t.get('/improve');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Improve OpenVibe'));
++ assert.ok(r.text.includes('href="/improve/OpenVibers/OpenVibe.Codes"'), r.text);
++ assert.ok(r.text.includes('OpenVibe.Codes'));
++ });
++
++ await check('a repository page shows the guided path and asks guests to sign in', async () => {
++ const r = await t.get('/improve/OpenVibers/OpenVibe.Codes');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('OpenVibe.Codes'));
++ assert.ok(r.text.includes('/auth/login?next='), 'guest sign-in link');
++ assert.ok(!r.text.includes('name="prompt"'), 'no task form for guests');
++ });
++
++ await check('a signed-in user sees the start form on a repository page', async () => {
++ const r = await t.get('/improve/OpenVibers/OpenVibe.Codes', { as: ada });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('name="prompt"'));
++ assert.ok(r.text.includes('action="/improve/start"'));
++ assert.ok(r.text.includes('name="push"'));
++ });
++
++ await check('posting from another site is refused', async () => {
++ const r = await post('/improve/start', ada, { repo: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes', prompt: 'x' }, { origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403);
++ });
++
++ await check('an unknown repository is 404', async () => {
++ const r = await t.get('/improve/OpenVibers/NotARepo');
++ assert.strictEqual(r.status, 404);
++ });
++
++ let taskId;
++ await check('starting a guided run creates a project if needed and starts a Run task', async () => {
++ const r = await post('/improve/start', ada, {
++ repo: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes',
++ ref: 'main',
++ prompt: 'Fix the typo in README',
++ agent: 'claude-code',
++ permission: 'edit',
++ push: '1',
++ pull_request: '1',
++ });
++ assert.strictEqual(r.status, 303, r.text);
++ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(r.headers.get('location'))[1];
++ const [, sent] = calls.find((c) => c[0] === 'submit');
++ assert.strictEqual(sent.subject, ada.subject);
++ assert.ok(sent.project, 'a project was created');
++ assert.strictEqual(sent.args.repo, 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes');
++ assert.strictEqual(sent.args.prompt, 'Fix the typo in README');
++ assert.strictEqual(sent.args.agent, 'claude-code');
++ assert.strictEqual(sent.args.permission, 'edit');
++ assert.strictEqual(sent.args.push.pull_request, true);
++ assert.ok(/^openvibe-codes\/improve-/.test(sent.args.push.branch), 'a new branch name is chosen');
++ });
++
++ await check('the task appears on the user API and the run page', async () => {
++ const me = await t.get('/api/v1/me/tasks', { as: ada });
++ assert.strictEqual(me.status, 200);
++ assert.ok(me.json().tasks.some((x) => x.id === taskId), 'user API lists it');
++ const page = await t.get('/run', { as: ada });
++ assert.ok(page.text.includes(`/run/tasks/${taskId}`));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/me-api.test.js b/test/me-api.test.js
nnew file mode 100644
iindex 0000000..d5bbba4
---- /dev/null
++++ b/test/me-api.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * User JSON API for coding tasks (server/http/me-api.js): a signed-in person can list, start, read and cancel their own
++ * tasks at /api/v1/me/tasks without a service token. A first task auto-creates a project when the person has none.
++ */
++const assert = require('assert');
++const { validate } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const calls = [];
++ let state = 'queued';
++ const run = {
++ enabled: true,
++ async submit(o) { calls.push(['submit', o]); return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
++ async read(jobId, who) { calls.push(['read', jobId, who]); return { id: jobId, state, result: null, error: null }; },
++ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); state = 'cancelled'; return { id: jobId, state }; },
++ async streamUrl() { throw new Error('not used'); },
++ };
++ const t = await boot({ run });
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++
++ const call = async (method, path, { as = null, body = undefined, origin } = {}) => {
++ const headers = {};
++ if (origin) headers.origin = origin;
++ return t.get(`/api/v1/me${path}`, { as, method, ...(body !== undefined ? { json: body } : {}), headers });
++ };
++
++ const ask = { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Add a test.' };
++
++ try {
++ await check('a guest is refused', async () => {
++ assert.strictEqual((await call('GET', '/tasks')).status, 401);
++ assert.strictEqual((await call('POST', '/tasks', { body: ask })).status, 401);
++ });
++
++ await check('a first task creates a project, starts the Run job and validates as codes.task-start-result@1', async () => {
++ const r = await call('POST', '/tasks', { as: ada, body: ask });
++ assert.strictEqual(r.status, 201, r.text);
++ const json = r.json();
++ assert.ok(validate('codes.task-start-result@1', json).valid, JSON.stringify(validate('codes.task-start-result@1', json).errors));
++ assert.strictEqual(json.task.state, 'queued');
++ assert.strictEqual(json.task.repo, ask.repo);
++ const submit = calls.find((c) => c[0] === 'submit')[1];
++ assert.strictEqual(submit.subject, ada.subject);
++ assert.ok(submit.project, 'a project was created');
++ assert.deepStrictEqual(submit.args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
++ });
++
++ await check('the task is listed and readable by its owner, not by another person', async () => {
++ const list = await call('GET', '/tasks', { as: ada });
++ assert.strictEqual(list.status, 200);
++ const tasks = list.json().tasks;
++ assert.strictEqual(tasks.length, 1);
++ const id = tasks[0].id;
++ const one = await call('GET', `/tasks/${id}`, { as: ada });
++ assert.strictEqual(one.status, 200);
++ assert.ok(validate('codes.task@1', one.json()).valid);
++ assert.strictEqual(one.json().id, id);
++ const other = await call('GET', `/tasks/${id}`, { as: bo });
++ assert.strictEqual(other.status, 404);
++ });
++
++ await check('cancel reaches Run and returns the final state', async () => {
++ const list = await call('GET', '/tasks', { as: ada });
++ const id = list.json().tasks[0].id;
++ const c = await call('POST', `/tasks/${id}/cancel`, { as: ada });
++ assert.strictEqual(c.status, 200);
++ assert.strictEqual(c.json().state, 'cancelled');
++ assert.ok(calls.some((x) => x[0] === 'cancel'));
++ });
++
++ await check('a cross-site JSON post is refused', async () => {
++ const r = await call('POST', '/tasks', { as: ada, body: ask, origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c95b115..d590265 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -44,7 +44,7 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
const base = `http://127.0.0.1:${port}`;
try {
let up = false;
-- for (let i = 0; i < 150 && !up; i++) {
++ for (let i = 0; i < 300 && !up; i++) {
up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 100));
}
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex c19bc7e..bd88874 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -41,9 +41,12 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
const REVIEWED = {
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
++ 'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
++ 'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',
++ 'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
};