openvibers/openvibe.run Commits
checks: the Run-side scheduler (App, webhook, planner, check runs, shadow) (#277)
Changes
@@@ -40,6 +40,24 @@ OV_OAUTH_CLIENT_SECRET=# Background jobs: the events relay and the dispatcher's poller. RUN_JOBS=off runs the API alone.#RUN_JOBS=on++# ── GitHub checks on shared nodes (server/checks/) ──────────++# RUN_SECRETS_KEY seals the checks App's private key, webhook secret and installation tokens at rest:++# one 32-byte key, base64 (standard or URL-safe) or 64 hex characters. Unset: /admin/checks says so and++# POST /webhooks/github answers 503. RUN_CHECKS_PROJECT is the project a check job belongs to.++#RUN_SECRETS_KEY=++#RUN_CHECKS_PROJECT=++# The repositories/accounts the scheduler accepts (default OpenVibers) and the org the App is registered in.++#RUN_CHECKS_OWNERS=OpenVibers++#RUN_CHECKS_ORG=OpenVibers++# The network owner allowed on /admin/checks: their subject (usr_…), or OWNER_USERNAME (default goosely).++#RUN_CHECKS_OWNER=++#OWNER_USERNAME=goosely++#RUN_CHECKS=off++#RUN_CHECKS_TICK_MS=5000++#RUN_CHECKS_TIMEOUT_MS=8000++#RUN_CHECKS_GITHUB_API=https://api.github.com++#RUN_CHECKS_GITHUB_WEB=https://github.com++# ── Project caps on a job's limits (run.job-create-request@1); over one is 422 run.limits.exceeded ──#RUN_LIMITS_MAX_WALL_MS=3600000#RUN_LIMITS_MAX_CPU_MS=3600000@@@ -125,6 +125,14 @@ refused like any other token without the grant.with the grant is refused. Every request body and query string is validated against its released contract, and so isevery answer before it is sent; errors are RFC 9457 `application/problem+json`.++## Checks++++The checks scheduler (`server/checks/`, [docs/checks.md](docs/checks.md)) turns a matching GitHub workflow job into a++Run `code` job on a node and reports a GitHub check run. The owner creates the App once at `/admin/checks` (the++manifest flow); GitHub calls `POST /webhooks/github`; `GET /api/v1/checks/agreement?repo=` gives the owner the node vs++Actions agreement. Its secrets (the App key, webhook secret, client secret and installation tokens) are sealed with++`RUN_SECRETS_KEY`; `RUN_CHECKS_PROJECT` is the project a check job belongs to.++## SessionsA session (contracts 0.223.0, the Desks plan's D6) is a browser desk Run places for one person and OpenVibe.Bot leasesnnew file mode 100644@@@ -0,0 +1,85 @@++# GitHub checks on shared nodes++++The checks scheduler (`server/checks/`, research-2026-11-13/shared-node-checks.md piece 2) plans a repository's++GitHub Actions workflow jobs and runs each one as an ordinary Run job on a node, then reports a GitHub check run.++It is **shadow first**: `openvibe / …` check runs are not required, and the Actions conclusion of the same++workflow job is stored beside the node one for the agreement figure.++++## Setup (owner)++++1. Generate one 32-byte `RUN_SECRETS_KEY`, base64 (standard or URL-safe) or 64 hex characters, and put it in++ `/etc/openvibe/run.env`. It seals the App's private key, webhook secret, client secret and installation tokens.++2. Set `RUN_CHECKS_PROJECT` to the project check jobs belong to (`prj_…`). A check job is a normal Run job and needs++ a project for its `run_job.project_id` and its `run.job.*` events; without it nothing is planned.++3. Sign in as the network owner and open `/admin/checks`. Press **Create the GitHub App**: the browser posts the++ manifest to `https://github.com/organizations/OpenVibers/settings/apps/new` and GitHub redirects back to++ `/admin/checks/created`, where Run exchanges the code and stores the App encrypted.++4. Press **Install … on OpenVibers** and choose the organization/repositories.++++Owner = `RUN_CHECKS_OWNER` (a `usr_…` subject) or `OWNER_USERNAME` (default `goosely`), the same owner name++Network's owner guard uses.++++## Routes++++| Route | Who | What |++|---|---|---|++| `GET /admin/checks` | owner session | App state (`none`, `created`, installed accounts), the manifest button and the install link |++| `GET /admin/checks/created` | owner session + state | exchanges the manifest code, seals the App |++| `POST /webhooks/github` | GitHub signature | HMAC-SHA256 raw body, delivery dedupe for seven days |++| `GET /api/v1/checks/agreement?repo=` | owner session | paired checks, agreement, average queue/run ms per repo |++++Only `push` to the default branch and `pull_request` `opened`/`synchronize`/`reopened` plan checks, and only for++repositories/installations under `RUN_CHECKS_OWNERS` (default `OpenVibers`). A pull request whose head repository is++outside the allow-list is ignored (a fork's workflow is never run). `workflow_job.completed` records the Actions++conclusion; every other delivery is answered `204`.++++## What is planned++++The workflows under `.github/workflows` at the event's SHA are read with an installation token (cached until five++minutes before it expires). A workflow is kept when its `on` names the event and its `branches`/`branches-ignore`++filter matches. Jobs are flattened:++++- `needs` become stages: stage 0 is queued at planning, a later stage only when every need succeeded; a failed or++ skipped need skips the job (and its check run says `skipped`).++- `strategy.matrix` expands with GitHub's `include`/`exclude` order; `max-parallel` limits the concurrently queued++ combinations of one matrix.++- A reusable workflow (`uses: owner/repo/.github/workflows/x.yml@ref`, or local `./…`) is read at its ref and++ expanded as a sub-play; its jobs carry the caller's needs and their check names are prefixed with the caller.++- `if:` is not evaluated: an expression the planner cannot read runs the job.++++Each planned job becomes one Run job:++++```++class: code, artifact: [email protected],++args: { kind: 'check', repo: <clone URL>, sha, workflow, job, matrix?, event, ref, timeout_ms },++requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background',++ objective: 'balanced', trust: ['first-party', 'user-owned'] },++egress: public, limits: { wall_ms, cpu_ms, mem_bytes: 4 GiB },++ttl_ms: min(wall_ms + 10 min, 24 h),++timeout: the job's timeout-minutes, default 30++```++++A node that goes away mid-job (Run ends failed/expired/cancelled with no result) is retried once on another node;++the second end settles the check run. A check that produces a conclusion is never retried.++++## Check runs and the shadow++++One GitHub check run per planned job, named `openvibe / <the job's Actions name>` (matrix values in parentheses),++is created `queued` at planning, moves to `in_progress` when the Run job starts, and completes with the node's++conclusion and a summary of its steps. `details_url` is Run's job API URL (`/api/v1/jobs/<id>`): Run has no HTML++job page today.++++Every completion upserts `run_checks_shadow` keyed by repo, SHA and job name, and `workflow_job` writes the Actions++conclusion into the same row. `GET /api/v1/checks/agreement` aggregates the paired rows.++++## Safety++++- The App key, webhook secret, client secret and every installation token are AES-256-GCM sealed under++ `RUN_SECRETS_KEY` (AAD per App field / installation id) before they reach a row. They are never logged, never in++ a response and never in a node input.++- A webhook is accepted only with a valid `X-Hub-Signature-256` (constant-time), a fresh `X-GitHub-Delivery`, and++ a repository and installation account on the allow-list.++- Steps get no secrets from Run; the node clones the repo with its own credential and `GITHUB_TOKEN` is never++ passed to the steps. The job's egress is public.++- Nothing starts at module load. The tick starts from `server/index.js` only with `RUN_JOBS` on; `RUN_JOBS=off`++ (drills/tests) makes the webhook record a delivery without reading a workflow or calling GitHub.nnew file mode 100644@@@ -0,0 +1,129 @@++-- phase: expand++-- The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2): the GitHub App made by the++-- manifest flow, the webhook deliveries, one plan per workflow run, the planned workflow jobs with their++-- check runs and Run jobs, and the shadow pair of a node conclusion beside the Actions one.++--++-- Secrets (the App private key, its webhook secret, its client secret and every installation token) are++-- only ever stored sealed by server/checks/crypto.js (AES-256-GCM, RUN_SECRETS_KEY): no column here holds++-- one in the clear, and no route, log or node input ever carries one.++CREATE TABLE IF NOT EXISTS run_checks_app (++ id integer PRIMARY KEY CHECK (id = 1),++ app_id bigint NOT NULL,++ slug text NOT NULL,++ name text NOT NULL,++ html_url text,++ pem_enc text NOT NULL,++ webhook_secret_enc text NOT NULL,++ client_id text,++ client_secret_enc text,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++-- The accounts the App is installed on: kept from installation webhooks and refreshed from GET /app/installations.++CREATE TABLE IF NOT EXISTS run_checks_installations (++ installation_id bigint PRIMARY KEY,++ account_login text NOT NULL,++ account_id bigint,++ account_type text,++ suspended_at bigint,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++-- Installation access tokens, cached until five minutes before they expire, sealed at rest.++CREATE TABLE IF NOT EXISTS run_checks_tokens (++ installation_id bigint PRIMARY KEY,++ token_enc text NOT NULL,++ expires_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++++-- Webhook deliveries are deduplicated by X-GitHub-Delivery and swept after seven days.++CREATE TABLE IF NOT EXISTS run_checks_deliveries (++ delivery_id text PRIMARY KEY,++ event text,++ received_at bigint NOT NULL,++ expires_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS run_checks_deliveries_expiry ON run_checks_deliveries (expires_at);++++-- One plan per matching workflow of one event (a push can match several workflow files).++CREATE TABLE IF NOT EXISTS run_checks_plans (++ id text PRIMARY KEY CHECK (id ~ '^chkplan_[0-9A-HJKMNP-TV-Z]{26}$'),++ delivery_id text,++ repo text NOT NULL,++ repo_owner text NOT NULL,++ sha text NOT NULL,++ event text NOT NULL CHECK (event IN ('push','pull_request')),++ ref text NOT NULL,++ branch text,++ installation_id bigint,++ workflow_path text NOT NULL,++ workflow_name text NOT NULL,++ state text NOT NULL CHECK (state IN ('active','succeeded','failed','skipped')),++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS run_checks_plans_state ON run_checks_plans (state, created_at);++++-- One planned workflow job (one matrix combination is one row). `check_state` is the GitHub check run's++-- status; `state` is the scheduler's: waiting (needs pending), queuing (a tick claimed it), queued,++-- running, completed.++CREATE TABLE IF NOT EXISTS run_checks_jobs (++ id text PRIMARY KEY CHECK (id ~ '^chkjob_[0-9A-HJKMNP-TV-Z]{26}$'),++ plan_id text NOT NULL REFERENCES run_checks_plans(id) ON DELETE CASCADE,++ job_key text NOT NULL,++ job_name text NOT NULL,++ display_name text NOT NULL,++ workflow_path text NOT NULL,++ workflow_job_key text NOT NULL,++ stage integer NOT NULL,++ needs jsonb NOT NULL DEFAULT '[]'::jsonb,++ matrix jsonb,++ matrix_group text,++ max_parallel integer,++ timeout_minutes integer NOT NULL,++ check_run_id bigint,++ check_state text NOT NULL DEFAULT 'queued' CHECK (check_state IN ('queued','in_progress','completed')),++ state text NOT NULL DEFAULT 'waiting' CHECK (state IN ('waiting','queuing','queued','running','completed')),++ conclusion text,++ run_job_id text,++ attempts integer NOT NULL DEFAULT 0,++ report_errors integer NOT NULL DEFAULT 0,++ queued_at bigint,++ started_at bigint,++ finished_at bigint,++ queue_ms bigint,++ run_ms bigint,++ node_id text,++ summary text,++ error text,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (plan_id, job_key)++);++CREATE INDEX IF NOT EXISTS run_checks_jobs_plan_state ON run_checks_jobs (plan_id, state);++CREATE INDEX IF NOT EXISTS run_checks_jobs_run_job ON run_checks_jobs (run_job_id);++++-- Shadow first: the same workflow job on a node and on Actions, one row, whichever arrives first.++CREATE TABLE IF NOT EXISTS run_checks_shadow (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ repo text NOT NULL,++ sha text NOT NULL,++ workflow_name text NOT NULL DEFAULT '',++ job_name text NOT NULL,++ check_run_id bigint,++ node_conclusion text,++ actions_conclusion text,++ queue_ms bigint,++ run_ms bigint,++ node_started_at bigint,++ node_finished_at bigint,++ actions_started_at bigint,++ actions_finished_at bigint,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS run_checks_shadow_lookup ON run_checks_shadow (repo, sha, job_name);++CREATE INDEX IF NOT EXISTS run_checks_shadow_repo ON run_checks_shadow (repo, created_at);@@@ -18,7 +18,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "yaml": "^2.9.1"},"devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1802,6 +1803,21 @@"engines": {"node": ">=0.4"}++ },++ "node_modules/yaml": {++ "version": "2.9.1",++ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",++ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",++ "license": "ISC",++ "bin": {++ "yaml": "bin.mjs"++ },++ "engines": {++ "node": ">= 14.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/eemeli"++ }}}}@@@ -29,7 +29,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "yaml": "^2.9.1"},"devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -35,6 +35,7 @@ const { createDeskClient } = require('./sessions/desks');const { createSessions } = require('./sessions/service');const { inputError } = require('./util');const { createRunReadiness, registerRunGauges } = require('./observability');++const { createChecks } = require('./checks');const VERSION = require('../package.json').version;// A create request may carry 512 KiB of args (run.job-create-request@1) on top of its inputs and limits,@@@ -62,6 +63,10 @@ function createApp(opts = {}) {const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });const fillNotifier = opts.fillNotifier || createFillNotifier(config, { fetchImpl, now });const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });++ // The checks scheduler (server/checks/): the GitHub App's owner page, the webhook and the agreement++ // query. It owns no module-level state and starts nothing here; server/index.js starts its tick with++ // the other background jobs. Without RUN_SECRETS_KEY the page says so and the webhook answers 503.++ const checks = opts.checks || createChecks({ config, db, jobsStore: require('./jobs/store'), events, now, fetchImpl, log, userAuth, sso: opts.checksSso || null });// Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.const desks = opts.desks || createDeskClient({ config, fetchImpl, now });const broker = opts.broker || require('./compute/broker').createBroker({ config, db, desks, now, fetchImpl, log });@@@ -118,6 +123,11 @@ function createApp(opts = {}) {app.use(http.middleware());app.use(cookieParser());app.use('/auth', require('./web-sso').webSso({ config, fetchImpl, log }));++ // The checks App's owner page and its webhook. The webhook reads the raw bytes GitHub signed, so it++ // mounts its own body parser here, before /api/v1's JSON parser and the catch-all.++ app.use('/admin/checks', checks.admin);++ app.post('/webhooks/github', express.raw({ type: '*/*', limit: '10mb' }), (req, res, next) => Promise.resolve(checks.webhook(req, res)).catch(next));++ app.get('/api/v1/checks/agreement', (req, res, next) => Promise.resolve(checks.agreement(req, res)).catch(next));// Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are// /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.@@@ -230,7 +240,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions, broker });++ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions, broker, checks });return app;}nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The checks scheduler's secrets at rest (research-2026-11-13/shared-node-checks.md, safety): the GitHub++ * App's private key, its webhook secret, its client secret and every installation access token are sealed++ * with AES-256-GCM under RUN_SECRETS_KEY before they reach a row. Nothing in this module logs a value.++ *++ * RUN_SECRETS_KEY is one 32-byte key, base64 (standard or URL-safe) or 64 hex characters. A missing or++ * malformed key is not fatal at boot: the App page says encryption is not installed and the webhook, which++ * cannot verify a signature without its secret, answers 503. The envelope is++ *++ * ovc1.<iv base64url>.<tag base64url>.<ciphertext base64url>++ *++ * with the additional data `run.checks|<purpose>`, so a ciphertext sealed for one App field cannot be++ * opened as another (or as an installation token).++ */++const crypto = require('crypto');++++const PREFIX = 'ovc1';++const KEY_BYTES = 32;++const IV_BYTES = 12;++const TAG_BYTES = 16;++++/** The 32-byte key from RUN_SECRETS_KEY, or null when unset/not exactly 32 bytes. Never throws with the value. */++function keyOf(raw) {++ if (raw == null || raw === '') return null;++ const text = String(raw).trim();++ if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, 'hex');++ if (/^[A-Za-z0-9+/_-]{43}=?$/.test(text)) {++ const bytes = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64');++ return bytes.length === KEY_BYTES ? bytes : null;++ }++ return null;++}++++const aadOf = (purpose) => Buffer.from(`run.checks|${String(purpose)}`, 'utf8');++++/** Seal one string. → the envelope text. Throws when the key is missing or malformed. */++function seal(key, value, purpose) {++ if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new TypeError('checks.crypto: a 32-byte key is required');++ const iv = crypto.randomBytes(IV_BYTES);++ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);++ cipher.setAAD(aadOf(purpose));++ const body = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);++ return [PREFIX, iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), body.toString('base64url')].join('.');++}++++/** Open one envelope. Throws a stable error (never the plaintext) when it is foreign, truncated or tampered with. */++function open(key, envelope, purpose) {++ if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new Error('checks.crypto: a 32-byte key is required');++ const parts = String(envelope || '').split('.');++ if (parts.length !== 4 || parts[0] !== PREFIX) throw new Error('checks.crypto: not a sealed checks value');++ const iv = Buffer.from(parts[1], 'base64url');++ const tag = Buffer.from(parts[2], 'base64url');++ const body = Buffer.from(parts[3], 'base64url');++ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error('checks.crypto: malformed envelope');++ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);++ decipher.setAAD(aadOf(purpose));++ decipher.setAuthTag(tag);++ return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');++}++++module.exports = { keyOf, seal, open, KEY_BYTES, PREFIX };nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * The small GitHub client the checks scheduler needs (no SDK): an App JWT, installation access tokens, the++ * contents of the workflow files at a SHA, check runs, the App's installations and the manifest conversion.++ *++ * Every call takes an explicit token. This module never logs a request header, a token or the PEM; an error++ * says an HTTP status and GitHub's own message, never the credential it was sent with. The base URL is a++ * config value so a test can point it at a stub and the production default stays https://api.github.com.++ */++const crypto = require('crypto');++++class GitHubError extends Error {++ constructor(code, detail, { status = null } = {}) {++ super(detail || code);++ this.name = 'GitHubError';++ this.code = code;++ this.detail = detail;++ this.status = status;++ }++}++++const b64url = (value) => Buffer.from(value).toString('base64url');++const trim = (u) => String(u || '').replace(/\/+$/, '');++++/** A GitHub App JWT (RS256, iss = the App id, under ten minutes), from the App's private key. */++function appJwt(app, nowMs = Date.now()) {++ const iat = Math.floor(nowMs / 1000) - 60;++ const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));++ const payload = b64url(JSON.stringify({ iat, exp: iat + 540, iss: String(app.app_id) }));++ const signature = crypto.sign('RSA-SHA256', Buffer.from(`${header}.${payload}`, 'utf8'), app.pem).toString('base64url');++ return `${header}.${payload}.${signature}`;++}++++function createGitHub({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ const checks = (config && config.checks) || {};++ const api = trim(checks.githubApi || 'https://api.github.com');++ const timeoutMs = Math.max(1000, Number(checks.requestTimeoutMs) || 8000);++ const agent = 'OpenVibe.Run-checks (+https://openvibe.run)';++++ /**++ * One request. `token` is a JWT or an installation token, `raw` asks for the contents API's raw body++ * (text). → { status, text, json } on success; throws GitHubError otherwise. Never echoes the token.++ */++ async function request(path, { method = 'GET', token = null, body = undefined, raw = false } = {}) {++ const headers = { Accept: raw ? 'application/vnd.github.raw+json' : 'application/vnd.github+json', 'User-Agent': agent, 'X-GitHub-Api-Version': '2022-11-28' };++ if (token) headers.Authorization = `Bearer ${token}`;++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetchImpl(`${api}${path}`, {++ method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (e) {++ const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';++ throw new GitHubError('checks.github_unavailable', `GitHub did not answer (${why})`);++ }++ const text = await res.text().catch(() => '');++ if (!res.ok) {++ let message = '';++ try { message = String((JSON.parse(text) || {}).message || '').slice(0, 300); } catch { /* not json */ }++ throw new GitHubError('checks.github_refused', `GitHub answered HTTP ${res.status}${message ? `: ${message}` : ''}`, { status: res.status });++ }++ let json = null;++ if (!raw && text) { try { json = JSON.parse(text); } catch { /* an empty or non-JSON answer is null */ } }++ return { status: res.status, text, json, headers: res.headers };++ }++++ /** A short-lived installation token (one hour). The caller caches and seals it; this module never does. */++ async function createInstallationToken(app, installationId) {++ const out = await request(`/app/installations/${encodeURIComponent(String(installationId))}/access_tokens`, {++ method: 'POST', token: appJwt(app, now()),++ });++ const token = out.json && out.json.token;++ const expiresAt = Date.parse(out.json && out.json.expires_at);++ if (typeof token !== 'string' || !token || !Number.isFinite(expiresAt)) throw new GitHubError('checks.github_refused', 'GitHub issued no installation token');++ return { token, expiresAt };++ }++++ /** One file at a ref through the contents API (raw), or null when GitHub says 404. */++ async function readFile(app, token, { owner, repo, path, ref }) {++ const encoded = String(path).split('/').map(encodeURIComponent).join('/');++ try {++ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encoded}?ref=${encodeURIComponent(ref)}`, { token, raw: true });++ return out.text;++ } catch (e) {++ if (e.status === 404) return null;++ throw e;++ }++ }++++ /** The workflow files under .github/workflows at a SHA → [{ path, source }]. A repo with none is an empty list. */++ async function listWorkflows(app, token, { owner, repo, sha }) {++ let listing;++ try {++ listing = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/.github/workflows?ref=${encodeURIComponent(sha)}`, { token });++ } catch (e) {++ if (e.status === 404) return [];++ throw e;++ }++ const files = Array.isArray(listing.json) ? listing.json : [];++ const out = [];++ for (const file of files) {++ if (!file || file.type !== 'file' || !/\.ya?ml$/i.test(String(file.name || ''))) continue;++ const source = await readFile(app, token, { owner, repo, path: file.path, ref: sha });++ if (source != null) out.push({ path: file.path, source });++ }++ return out;++ }++++ /** Create a check run (queued) → { id, html_url }. */++ async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null }) {++ const body = { name, head_sha: headSha, status: 'queued', ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };++ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs`, { method: 'POST', token, body });++ if (!out.json || !out.json.id) throw new GitHubError('checks.github_refused', 'GitHub created no check run');++ return { id: out.json.id, html_url: out.json.html_url || null };++ }++++ /** Update a check run (status, conclusion, summary, details link). */++ async function updateCheckRun(token, { owner, repo, checkRunId, status, conclusion = null, output = null, detailsUrl = null, startedAt = null, completedAt = null }) {++ const body = {++ status,++ ...(conclusion ? { conclusion } : {}),++ ...(output ? { output } : {}),++ ...(detailsUrl ? { details_url: detailsUrl } : {}),++ ...(startedAt ? { started_at: startedAt } : {}),++ ...(completedAt ? { completed_at: completedAt } : {}),++ };++ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs/${encodeURIComponent(String(checkRunId))}`, { method: 'PATCH', token, body });++ return out.json;++ }++++ /** Exchange a manifest flow code for the App's id, slug, PEM, webhook secret and client credentials. */++ async function convertManifest(code) {++ const out = await request(`/app-manifests/${encodeURIComponent(code)}/conversions`, { method: 'POST' });++ const app = out.json;++ if (!app || !app.id || !app.pem || !app.webhook_secret) throw new GitHubError('checks.github_refused', 'GitHub returned an incomplete App registration');++ return app;++ }++++ /** Every installation of the App (GET /app/installations), for the owner page. */++ async function listInstallations(app) {++ const out = await request('/app/installations?per_page=100', { token: appJwt(app, now()) });++ return Array.isArray(out.json) ? out.json : [];++ }++++ return { request, createInstallationToken, readFile, listWorkflows, createCheckRun, updateCheckRun, convertManifest, listInstallations, api };++}++++module.exports = { createGitHub, appJwt, GitHubError };nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++/**++ * The checks scheduler surface: createChecks (server/checks/service.js) plus the two pieces a reviewer or a++ * test may want directly — the planner and the secret envelope. Nothing here starts at module load.++ */++const { createChecks, manifestFor, safeEqual } = require('./service');++const planner = require('./planner');++const crypto = require('./crypto');++++module.exports = { createChecks, manifestFor, safeEqual, planner, checksCrypto: crypto };nnew file mode 100644@@@ -0,0 +1,289 @@++'use strict';++/**++ * The checks planner (research-2026-11-13/shared-node-checks.md, piece 2): act's reading of a workflow, not++ * an evaluator of it. A workflow at the SHA is kept when its `on` names the event and its branch filters++ * match; its jobs are expanded into one flat play per workflow job, matrix combination included, with++ *++ * - `needs` turned into stages (a later stage is queued only when every need succeeded),++ * - `strategy.matrix` with include/exclude and max-parallel,++ * - a reusable workflow (`uses: owner/repo/.github/workflows/x.yml@ref`) read at its ref and expanded as++ * a sub-play (the caller job is not a Run job; its sub-jobs carry its needs).++ *++ * `if:` is never evaluated: an expression this planner cannot read runs the job, which is act's default.++ * The reading of files (GitHub contents, installation token) is a callback so this module is pure and a++ * fixture test needs no network.++ */++const YAML = require('yaml');++++const DEFAULT_TIMEOUT_MINUTES = 30;++const MAX_REUSE_DEPTH = 4;++const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);++const listOf = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]);++const baseName = (p) => String(p || '').split('/').pop().replace(/\.ya?ml$/i, '');++++/** The `on` map of a workflow: a string, a list of strings, or the event → filters map. */++function onMap(on) {++ if (typeof on === 'string') return { [on]: null };++ if (Array.isArray(on)) return Object.fromEntries(on.filter((e) => typeof e === 'string').map((e) => [e, null]));++ const m = obj(on);++ return m ? { ...m } : {};++}++++/** A GitHub branch glob (`*` one segment, `**` any) as a regular expression. */++function globOf(pattern) {++ let re = '';++ const text = String(pattern || '');++ for (let i = 0; i < text.length; i++) {++ const c = text[i];++ if (c === '*') {++ if (text[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*';++ } else if (c === '?') re += '[^/]';++ else re += c.replace(/[\\^$+.()|[\]{}]/g, '\\$&');++ }++ try { return new RegExp(`^${re}$`); } catch { return null; }++}++++/** Does one branch match a GitHub `branches` list (positive globs and `!` negations)? */++function branchMatches(branch, patterns) {++ const list = listOf(patterns).filter((p) => typeof p === 'string');++ if (!list.length) return true;++ const negative = list.filter((p) => p.startsWith('!')).map((p) => globOf(p.slice(1))).filter(Boolean);++ const positive = list.filter((p) => !p.startsWith('!')).map(globOf).filter(Boolean);++ if (negative.some((re) => re.test(branch))) return false;++ if (positive.length && !positive.some((re) => re.test(branch))) return false;++ return true;++}++++/**++ * Does a workflow's `on` match this event? `branch` is the branch the event acts on: the pushed branch,++ * or a pull request's base branch. `paths` filters are not evaluated (the job runs), as act does.++ */++function eventMatches(on, { event, branch = null, action = null } = {}) {++ const map = onMap(on);++ if (!Object.prototype.hasOwnProperty.call(map, event)) return false;++ const cfg = map[event];++ if (cfg == null) return true;++ const filters = obj(cfg);++ if (!filters) return true;++ const types = listOf(filters.types);++ if (types.length && action && !types.includes(action)) return false;++ if (filters['branches-ignore'] && branch && listOf(filters['branches-ignore']).some((p) => (globOf(p) || /$^/).test(branch))) return false;++ if (filters.branches && branch && !branchMatches(branch, filters.branches)) return false;++ // A push filter naming only tags does not run for a branch push.++ if (event === 'push' && filters.tags && !filters.branches && branch) return false;++ return true;++}++++// ── strategy.matrix ────────────────────────────────────────────────────────────++++/**++ * Every combination of `strategy.matrix`, or [null] when there is none. include entries are merged into++ * every combination whose original matrix keys they do not overwrite (a same value is not an overwrite);++ * an include that fits none becomes a combination of its own. exclude removes combinations matching all++ * of its pairs. This is GitHub's documented order.++ */++function matrixCombinations(strategy) {++ const s = obj(strategy);++ const matrix = s && obj(s.matrix);++ if (!matrix) return [null];++ const keys = Object.keys(matrix).filter((k) => k !== 'include' && k !== 'exclude');++ let combos = [{}];++ for (const key of keys) {++ const values = listOf(matrix[key]);++ const next = [];++ for (const combo of combos) for (const value of values) next.push({ ...combo, [key]: value });++ combos = next;++ }++ for (const include of listOf(matrix.include)) {++ const inc = obj(include);++ if (!inc) continue;++ let merged = false;++ for (let i = 0; i < combos.length; i++) {++ const combo = combos[i];++ const overwrites = Object.keys(inc).some((k) => keys.includes(k) && Object.prototype.hasOwnProperty.call(combo, k) && combo[k] !== inc[k]);++ if (overwrites) continue;++ combos[i] = { ...combo, ...inc };++ merged = true;++ }++ if (!merged) combos.push({ ...inc });++ }++ for (const exclude of listOf(matrix.exclude)) {++ const ex = obj(exclude);++ if (!ex) continue;++ const pairs = Object.entries(ex);++ combos = combos.filter((combo) => !pairs.every(([k, v]) => combo[k] === v));++ }++ return combos;++}++++/** The `(18, ubuntu-latest)` part of a matrix job's Actions name; empty for no matrix. */++function matrixLabel(combo) {++ if (!combo) return '';++ const values = Object.values(combo);++ if (!values.length) return '';++ return ` (${values.map((v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v))).join(', ')})`;++}++++const maxParallelOf = (strategy) => {++ const s = obj(strategy);++ const n = s ? Number(s['max-parallel']) : NaN;++ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;++};++++const timeoutOf = (job) => {++ const n = Number(job && job['timeout-minutes']);++ return Number.isFinite(n) && n > 0 ? Math.min(24 * 60, Math.floor(n)) : DEFAULT_TIMEOUT_MINUTES;++};++++// ── jobs, needs and reusable workflows ─────────────────────────────────────────++++/** Parse a job's `uses`: a local path (read at the plan's SHA) or owner/repo/path@ref. */++function parseUses(uses) {++ const text = String(uses || '').trim();++ if (!text) return null;++ if (text.startsWith('./') || text.startsWith('.github/')) return { local: true, path: text.replace(/^\.\//, ''), ref: null };++ const m = /^([^/\s]+)\/([^/\s]+)\/(.+)@([^\s]+)$/.exec(text);++ if (!m) return null;++ return { local: false, owner: m[1], repo: m[2], path: m[3], ref: m[4] };++}++++/**++ * Expand a workflow's jobs, recursively through reusable workflows. Returns entries with abstract needs++ * (keys in this play) and `provides`: a workflow job key → the entries that satisfy a `needs` on it (a++ * matrix job's variants, or every job of a reusable workflow).++ */++async function expandWorkflow({ doc, path, keyPrefix = '', displayPrefix = '', inheritedNeeds = [], callerMatrix = null,++ readWorkflow, depth = 0, errors = [] }) {++ const entries = [];++ const provides = new Map();++ const jobs = obj(doc && doc.jobs) || {};++ for (const [jobKey, jobValue] of Object.entries(jobs)) {++ const job = obj(jobValue) || {};++ const fullKey = `${keyPrefix}${jobKey}`;++ const jobName = typeof job.name === 'string' && job.name ? job.name : jobKey;++ const needsHere = listOf(job.needs).filter((n) => typeof n === 'string').map((n) => `${keyPrefix}${n}`);++++ if (typeof job.uses === 'string' && job.uses) {++ const uses = parseUses(job.uses);++ if (!uses) { errors.push({ path, job: jobKey, message: `cannot read uses: ${job.uses}` }); continue; }++ if (depth >= MAX_REUSE_DEPTH) { errors.push({ path, job: jobKey, message: 'reusable workflows nest too deeply' }); continue; }++ let source = null;++ try { source = readWorkflow ? await readWorkflow(uses, path) : null; } catch (e) {++ errors.push({ path, job: jobKey, message: `reusable workflow ${job.uses} could not be read (${e.code || e.message})` });++ }++ let called = null;++ if (source) {++ try { called = YAML.parse(source); } catch (e) { errors.push({ path, job: jobKey, message: `reusable workflow ${job.uses} is not YAML` }); }++ }++ if (!called || !obj(called.jobs)) { provides.set(fullKey, []); continue; }++ const callerCombos = matrixCombinations(job.strategy);++ for (let ci = 0; ci < callerCombos.length; ci++) {++ const combo = callerCombos[ci];++ const callKey = callerCombos.length > 1 ? `${fullKey}#${ci}` : fullKey;++ const childPrefix = `${callKey}/`;++ const childDisplay = `${displayPrefix}${jobName}${matrixLabel(combo)} / `;++ const childInherited = needsHere.length ? needsHere : inheritedNeeds;++ const childMatrix = combo ? { ...(callerMatrix || {}), ...combo } : callerMatrix;++ const child = await expandWorkflow({++ doc: called, path: uses.path, keyPrefix: childPrefix, displayPrefix: childDisplay,++ inheritedNeeds: childInherited, callerMatrix: childMatrix, readWorkflow, depth: depth + 1, errors,++ });++ entries.push(...child.entries);++ // child.provides' keys already carry childPrefix: merge them as they are.++ for (const [k, v] of child.provides) provides.set(k, v);++ if (!provides.has(fullKey)) provides.set(fullKey, []);++ provides.get(fullKey).push(...child.entries.map((e) => e.key));++ }++ if (!provides.has(fullKey)) provides.set(fullKey, []);++ continue;++ }++++ const combos = matrixCombinations(job.strategy);++ const maxParallel = maxParallelOf(job.strategy);++ const timeout = timeoutOf(job);++ const variants = [];++ for (let ci = 0; ci < combos.length; ci++) {++ const combo = combos[ci] ? { ...(callerMatrix || {}), ...combos[ci] } : callerMatrix;++ const key = combos.length > 1 ? `${fullKey}#${ci}` : fullKey;++ const displayName = `${displayPrefix}${jobName}${matrixLabel(combo)}`;++ entries.push({++ key, group: fullKey, job_key: jobKey, job_name: jobName, display_name: displayName,++ workflow_path: path, workflow_job_key: jobKey, needs: needsHere.length ? needsHere : inheritedNeeds, matrix: combo,++ max_parallel: combos.length > 1 ? maxParallel : null, timeout_minutes: timeout,++ name_prefix: displayPrefix,++ });++ variants.push(key);++ }++ provides.set(fullKey, variants);++ }++ return { entries, provides };++}++++/** Resolve abstract needs to entry keys, then compute each entry's stage. Cycles and unknown needs are errors. */++function resolveStages(entries, provides, errors = []) {++ const byKey = new Map(entries.map((e) => [e.key, e]));++ const stageOf = new Map();++ const visiting = new Set();++ function stage(key) {++ if (stageOf.has(key)) return stageOf.get(key);++ const entry = byKey.get(key);++ if (!entry) return 0;++ if (visiting.has(key)) throw new Error(`a needs cycle passes through ${key}`);++ visiting.add(key);++ let n = 0;++ for (const need of entry.needs) n = Math.max(n, stage(need) + 1);++ visiting.delete(key);++ stageOf.set(key, n);++ return n;++ }++ const kept = [];++ for (const entry of entries) {++ const resolved = [];++ for (const need of entry.needs) {++ const supplied = provides.get(need);++ if (supplied) resolved.push(...supplied);++ else if (byKey.has(need)) resolved.push(need);++ else errors.push({ path: entry.workflow_path, job: entry.job_key, message: `needs ${need}, which the workflow does not define` });++ }++ entry.needs = [...new Set(resolved.filter((k) => k !== entry.key))];++ kept.push(entry);++ }++ for (const entry of kept) {++ try { entry.stage = stage(entry.key); } catch (e) {++ errors.push({ path: entry.workflow_path, job: entry.job_key, message: e.message });++ entry.error = true;++ }++ }++ return kept.filter((e) => !e.error).sort((a, b) => a.stage - b.stage || a.key.localeCompare(b.key));++}++++/**++ * planWorkflows({ workflows: [{path, source}], event, branch, action, readWorkflow, log }) →++ * { workflows: [{ path, name, jobs }], errors }. Every job is flat: one matrix combination is one job;++ * `needs` is the list of flat keys it waits for; `stage` is its level.++ */++async function planWorkflows({ workflows = [], event, branch = null, action = null, readWorkflow = null, log = console } = {}) {++ const out = { workflows: [], errors: [] };++ for (const file of workflows) {++ let doc;++ try { doc = YAML.parse(file.source); } catch (e) {++ out.errors.push({ path: file.path, message: `not YAML: ${e.message}` });++ continue;++ }++ if (!obj(doc) || !obj(doc.jobs)) continue;++ if (!eventMatches(doc.on, { event, branch, action })) continue;++ const name = typeof doc.name === 'string' && doc.name ? doc.name : baseName(file.path);++ const expanded = await expandWorkflow({ doc, path: file.path, readWorkflow, errors: out.errors });++ const jobs = resolveStages(expanded.entries, expanded.provides, out.errors);++ if (jobs.length) out.workflows.push({ path: file.path, name, jobs });++ if (out.errors.length && log && log.warn) {++ for (const e of out.errors.slice(-3)) log.warn(`[Run] checks planner: ${e.path}${e.job ? ` ${e.job}` : ''}: ${e.message}`);++ }++ }++ return out;++}++++module.exports = {++ planWorkflows, eventMatches, matrixCombinations, matrixLabel, parseUses, onMap, branchMatches, globOf,++ DEFAULT_TIMEOUT_MINUTES,++};nnew file mode 100644@@@ -0,0 +1,726 @@++'use strict';++/**++ * The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2).++ *++ * /admin/checks the network owner's page: the App's state and the manifest-flow button++ * /admin/checks/created the manifest redirect: state checked, code exchanged, secrets sealed++ * POST /webhooks/github raw body, HMAC-SHA256 of the stored webhook secret (constant time),++ * X-GitHub-Delivery dedupe, only the allow-list's repositories, only++ * pull_request opened/synchronize/reopened and push to the default branch++ * GET /api/v1/checks/agreement node vs Actions agreement, queue ms and run ms per repo (owner only)++ *++ * A matching event reads the workflows at the SHA with an installation token, plans act's jobs, creates one++ * queued GitHub check run per planned job and queues the first stage as ordinary Run jobs (class code,++ * `[email protected]`, args `{ kind: 'check', … }`). A tick follows the Run jobs, reports in_progress and++ * completed to GitHub, retries a node that went away once, skips the jobs that need a job that did not++ * succeed, and writes the shadow pair of the node's conclusion beside the Actions one.++ *++ * Nothing runs at module load. server/index.js starts the tick only with RUN_JOBS on; the webhook and the++ * owner page are request-driven. Every outbound call needs the App: before it exists the webhook answers++ * 503 and the page shows how to create it.++ */++const crypto = require('crypto');++const express = require('express');++const { ids } = require('openvibe-contracts');++const { createSsoClient } = require('openvibe-sdk/sso');++const store = require('./store');++const { createGitHub } = require('./github');++const { planWorkflows } = require('./planner');++const { keyOf, seal, open } = require('./crypto');++const views = require('./views');++++const TOKEN_MARGIN_MS = 5 * 60_000;++const DELIVERY_TTL_MS = 7 * 24 * 3600_000;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const SHA_RE = /^[0-9a-f]{40}$/i;++const CHECK_CONCLUSIONS = new Set(['success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required', 'stale']);++const MAP_CONCLUSION = { success: 'success', failure: 'failure', cancelled: 'cancelled', timed_out: 'timed_out', timedout: 'timed_out', skipped: 'skipped' };++const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});++const parseJson = (v) => {++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;++ try { return obj(JSON.parse(v)); } catch { return {}; }++};++const safeEqual = (a, b) => {++ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++};++const newPlanId = () => `chkplan_${ids.ulid()}`;++const newJobId = () => `chkjob_${ids.ulid()}`;++++/** The manifest posted to GitHub. Public configuration only; the secrets come back from the conversion call. */++function manifestFor({ baseUrl } = {}) {++ return {++ name: 'OpenVibe Checks',++ url: baseUrl,++ hook_attributes: { url: `${baseUrl}/webhooks/github`, active: true },++ redirect_url: `${baseUrl}/admin/checks/created`,++ public: false,++ default_permissions: {++ checks: 'write', statuses: 'write', contents: 'read', pull_requests: 'read', metadata: 'read', actions: 'read',++ },++ default_events: ['push', 'pull_request', 'check_suite', 'workflow_job'],++ };++}++++/**++ * createChecks({ config, db, jobsStore, events, now, fetchImpl, log, sso }) → the checks service.++ * `jobsStore` is server/jobs/store.js (mint, frameOf, …); `events` is server/jobs/events.js.++ */++function createChecks({ config, db, jobsStore = null, events = null, now = () => Date.now(), fetchImpl = globalThis.fetch, log = console, userAuth = null, sso = null } = {}) {++ const cfg = (config && config.checks) || {};++ const key = keyOf(cfg.secretsKey);++ const owners = (Array.isArray(cfg.owners) && cfg.owners.length ? cfg.owners : ['OpenVibers'])++ .map((o) => String(o).toLowerCase()).filter(Boolean);++ const org = cfg.org || 'OpenVibers';++ const project = PROJECT_RE.test(String(cfg.project || '')) ? String(cfg.project) : null;++ const githubWeb = String(cfg.githubWeb || 'https://github.com').replace(/\/+$/, '');++ const tickMs = Math.max(500, Number(cfg.tickMs) || 5000);++ const enabled = cfg.enabled !== false && Boolean(key);++ const github = createGitHub({ config, fetchImpl, now, log });++ const tokenCache = new Map(); // installation id → { token, expiresAt }++ let timer = null;++ let loggedNoProject = false;++++ let ssoClient = sso;++ if (!ssoClient && config && config.baseUrl && config.oauth && config.oauth.clientId) {++ try {++ ssoClient = createSsoClient({++ site: 'run', siteName: 'OpenVibe.Run', baseUrl: config.baseUrl,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ redirectUri: `${config.baseUrl}/auth/callback`, scope: 'profile theme',++ networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,++ issuer: config.network.issuer, publicKey: config.network.publicKey, audience: ['openvibe.network'],++ secureCookies: config.isProduction, httpOnlyAccess: true, fetch: fetchImpl, log,++ });++ } catch (e) { log.warn(`[Run] checks: sign-in for /admin/checks is unavailable (${e.message})`); }++ }++ const optionalAuth = ssoClient ? ssoClient.optionalAuth() : (_req, _res, next) => next();++++ /** A Bearer Network user token as a session (the API's own verify), for a script or the owner's API client. */++ function bearerAuth(req, res, next) {++ if (req.user || !userAuth) return next();++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return next();++ userAuth.verify(header.slice(7).trim()).then((claims) => { if (claims) req.user = claims; next(); }, () => next());++ }++++ /** The configured owner: their Network subject, or the Network owner's username (the same rule Network uses). */++ function isOwner(user) {++ if (!user) return false;++ const subject = String(user.subject_id || '');++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return false;++ if (cfg.owner && subject === cfg.owner) return true;++ const username = String(user.username || '').toLowerCase();++ return Boolean(username) && username === String(cfg.ownerUsername || 'goosely').toLowerCase();++ }++++ function ownerPage(req, res, next) {++ optionalAuth(req, res, () => bearerAuth(req, res, () => {++ if (!req.user) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl || '/admin/checks')}`);++ if (!isOwner(req.user)) return res.status(403).type('html').send(views.errorPage({ title: 'Owner access required', text: 'Only the network owner can manage the checks App.', back: '/' }));++ return next();++ }));++ }++++ function ownerApi(req, res, next) {++ optionalAuth(req, res, () => bearerAuth(req, res, () => {++ if (!req.user) return res.status(401).json({ error: 'owner session required' });++ if (!isOwner(req.user)) return res.status(403).json({ error: 'owner access required' });++ return next();++ }));++ }++++ /** The App row with its sealed fields opened, or null. Never exposes a secret; `unreadable` says a key is wrong. */++ async function appRow() {++ const row = await store.getApp(db);++ if (!row) return null;++ if (!key) return { ...row, unreadable: true };++ try {++ return {++ ...row,++ pem: open(key, row.pem_enc, `app:${row.app_id}:pem`),++ webhook_secret: open(key, row.webhook_secret_enc, `app:${row.app_id}:webhook_secret`),++ client_secret: row.client_secret_enc ? open(key, row.client_secret_enc, `app:${row.app_id}:client_secret`) : null,++ };++ } catch (e) {++ log.warn(`[Run] checks: the App secrets cannot be opened (${e.message}); create the App again`);++ return { ...row, unreadable: true };++ }++ }++++ /** An installation token, from memory or the sealed cache, refreshed five minutes before it expires. */++ async function tokenFor(app, installationId) {++ if (!app || app.unreadable || !app.pem) throw new Error('checks: the App has no readable key');++ const id = Number(installationId);++ const held = tokenCache.get(id);++ if (held && held.expiresAt - now() > TOKEN_MARGIN_MS) return held.token;++ const row = await store.getToken(db, id).catch(() => null);++ if (row && Number(row.expires_at) - now() > TOKEN_MARGIN_MS) {++ try {++ const token = open(key, row.token_enc, `installation:${id}`);++ tokenCache.set(id, { token, expiresAt: Number(row.expires_at) });++ return token;++ } catch { await store.deleteToken(db, id).catch(() => null); }++ }++ const issued = await github.createInstallationToken(app, id);++ const sealed = seal(key, issued.token, `installation:${id}`);++ await store.putToken(db, id, sealed, issued.expiresAt, now()).catch((e) => log.warn(`[Run] checks: the installation token cache could not be written (${e.code || e.message})`));++ tokenCache.set(id, issued);++ return issued.token;++ }++++ /** Context for one plan: the App and an installation token, best effort (a missing token only loses the report). */++ async function planContext(plan) {++ const app = await appRow();++ if (!app || app.unreadable) return { app, token: null };++ if (!plan || !plan.installation_id) return { app, token: null };++ try { return { app, token: await tokenFor(app, plan.installation_id) }; } catch (e) {++ log.warn(`[Run] checks: no installation token for ${plan.repo} (${e.code || e.message})`);++ return { app, token: null };++ }++ }++++ const detailsUrlOf = (runJobId) => (runJobId ? `${config.baseUrl}/api/v1/jobs/${encodeURIComponent(runJobId)}` : null);++++ // ── planning ───────────────────────────────────────────────────────────────++++ /**++ * Plan one webhook event: read the workflows at the SHA, keep the ones whose `on` matches, expand jobs,++ * create a plan, one queued check run per job, and queue the first stage. → { plans, errors }.++ */++ async function planForEvent({ repo, owner, name, sha, event, ref, branch, action = null, installationId, deliveryId = null }) {++ // The drill/test switch: RUN_JOBS=off means the background side is not running, so a delivery is++ // recorded and answered, but no workflow is read and no outbound call is made.++ if (!enabled) return { plans: [], skipped: 'disabled' };++ if (!config.jobs.enabled) return { plans: [], skipped: 'jobs_off' };++ if (!project) {++ if (!loggedNoProject) { log.warn('[Run] checks: RUN_CHECKS_PROJECT is not set, so no workflow is planned'); loggedNoProject = true; }++ return { plans: [], skipped: 'no_project' };++ }++ if (!SHA_RE.test(String(sha || ''))) return { plans: [], skipped: 'bad_sha' };++ const app = await appRow();++ if (!app || app.unreadable || !app.pem) return { plans: [], skipped: 'no_app' };++ const token = await tokenFor(app, installationId);++ const workflows = await github.listWorkflows(app, token, { owner, repo: name, sha });++ const readWorkflow = async (uses) => {++ if (uses.local) return github.readFile(app, token, { owner, repo: name, path: uses.path, ref: sha });++ if (!owners.includes(String(uses.owner || '').toLowerCase())) return null;++ return github.readFile(app, token, { owner: uses.owner, repo: uses.repo, path: uses.path, ref: uses.ref });++ };++ const planned = await planWorkflows({ workflows, event, branch, action, readWorkflow, log });++ const made = [];++ for (const wf of planned.workflows) {++ const plan = {++ id: newPlanId(), delivery_id: deliveryId, repo, owner, sha, event, ref, branch,++ installation_id: installationId, workflow_path: wf.path, workflow_name: wf.name,++ };++ const jobs = wf.jobs.map((job) => ({ ...job, id: newJobId(), job_key: job.key, matrix_group: job.group && job.max_parallel ? job.group : null }));++ await db.tx((t) => store.insertPlan(t, { plan, jobs }, now()));++ made.push(plan);++ await ensureCheckRuns(plan, jobs, token);++ await queueReady(plan.id);++ }++ return { plans: made, errors: planned.errors };++ }++++ /** Create the missing GitHub check runs for a plan's jobs (queued). A GitHub failure is logged, never fatal. */++ async function ensureCheckRuns(plan, jobs, token) {++ if (!token) return;++ for (const job of jobs) {++ if (job.check_run_id || job.state === 'completed') continue;++ try {++ const run = await github.createCheckRun(token, {++ owner: plan.owner, repo: plan.repo.split('/')[1], name: `openvibe / ${job.display_name}`,++ headSha: plan.sha, detailsUrl: detailsUrlOf(job.run_job_id), externalId: job.id,++ });++ await store.setCheckRun(db, job.id, run.id, now());++ } catch (e) {++ log.warn(`[Run] checks: check run for ${plan.repo} ${job.display_name} was not created (${e.code || e.message})`);++ }++ }++ }++++ // ── running and finishing ──────────────────────────────────────────────────++++ /** The conclusion a node job's result/state means, in GitHub's vocabulary. */++ function conclusionOf(row, result) {++ const direct = MAP_CONCLUSION[String(result && result.conclusion || '').toLowerCase()];++ if (direct) return direct;++ if (result && result.ok === false) return 'failure';++ if (row.run_state === 'succeeded') return 'success';++ if (row.run_state === 'cancelled') return 'cancelled';++ if (row.run_state === 'expired') return row.run_exit_reason === 'ttl' ? 'timed_out' : 'failure';++ return 'failure';++ }++++ function summaryOf(job, result, row) {++ const steps = Array.isArray(result && result.steps) ? result.steps.slice(0, 100) : [];++ const lines = [];++ if (steps.length) {++ lines.push('| Step | Result | Time |', '| --- | --- | --- |');++ for (const step of steps) {++ const name = String(obj(step).name || 'step').replace(/\|/g, '\\|').slice(0, 200);++ const conclusion = String(obj(step).conclusion || '').replace(/\|/g, '\\|').slice(0, 40);++ const ms = Number(obj(step).ms);++ lines.push(`| ${name} | ${conclusion} | ${Number.isFinite(ms) && ms >= 0 ? `${ms} ms` : ''} |`);++ }++ } else {++ lines.push(`The node job ended ${row.run_state}${row.run_exit_reason ? ` (${row.run_exit_reason})` : ''}.`);++ }++ if (row.run_error_code) lines.push('', `Run error: \`${String(row.run_error_code).slice(0, 100)}\``);++ return lines.join('\n').slice(0, 60_000);++ }++++ /**++ * Report one completed workflow job to GitHub, then mark it complete and write the shadow pair. A failed++ * report is retried by later ticks (up to five times), so a short GitHub outage does not lose the check.++ * → true when the row is complete.++ */++ async function finishJob(plan, job, { conclusion, summary, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null }) {++ const at = finishedAt || now();++ if (job.check_run_id) {++ const { token } = await planContext(plan);++ if (!token) {++ log.warn(`[Run] checks: no token; ${plan.repo} ${job.display_name} completes without a check run update`);++ } else {++ try {++ await github.updateCheckRun(token, {++ owner: plan.owner, repo: plan.repo.split('/')[1], checkRunId: job.check_run_id,++ status: 'completed', conclusion: CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure',++ output: { title: job.display_name.slice(0, 250), summary: summary || 'Completed' },++ detailsUrl: detailsUrlOf(job.run_job_id), completedAt: new Date(at).toISOString(),++ });++ } catch (e) {++ const bumped = await store.bumpReportError(db, job.id, now());++ const errors = Number((bumped && bumped.report_errors) || 0);++ if (errors < 5) {++ log.warn(`[Run] checks: check run for ${plan.repo} ${job.display_name} was not updated (${e.code || e.message}); retrying`);++ return false;++ }++ log.warn(`[Run] checks: giving up on check run ${job.check_run_id} after ${errors} failures (${e.code || e.message})`);++ }++ }++ }++ const done = await store.completeJob(db, job.id, {++ conclusion: CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure', summary, error,++ queueMs, runMs, startedAt, finishedAt: at, nodeId, at,++ });++ if (done) {++ await store.shadowNode(db, {++ repo: plan.repo, sha: plan.sha, workflowName: plan.workflow_name, jobName: job.display_name,++ checkRunId: job.check_run_id, conclusion: done.conclusion, queueMs: done.queue_ms, runMs: done.run_ms,++ startedAt: done.started_at, finishedAt: done.finished_at, at,++ }).catch((e) => log.warn(`[Run] checks: shadow row for ${plan.repo} failed (${e.code || e.message})`));++ }++ return true;++ }++++ /** A job whose need did not succeed is skipped; its check run says so. */++ async function skipJob(plan, job, reason) {++ return finishJob(plan, job, { conclusion: 'skipped', summary: `Skipped: ${reason}`, finishedAt: now() });++ }++++ /** Queue one waiting workflow job as a Run job (class code, [email protected]) in one transaction. */++ async function queueJob(plan, job) {++ const claimed = await store.claimQueue(db, job.id, now());++ if (!claimed) return null;++ const timeoutMs = Math.max(1, Number(claimed.timeout_minutes) || 30) * 60_000;++ const matrix = parseJson(claimed.matrix);++ const body = {++ class: 'code',++ artifact: { name: 'openvibe-codes', version: '1.0.0' },++ args: {++ kind: 'check',++ repo: `https://github.com/${plan.repo}.git`,++ sha: plan.sha,++ workflow: claimed.workflow_path,++ job: claimed.workflow_job_key,++ ...(Object.keys(matrix || {}).length ? { matrix } : {}),++ event: plan.event,++ ref: plan.ref,++ timeout_ms: timeoutMs,++ },++ limits: { wall_ms: timeoutMs, cpu_ms: timeoutMs, mem_bytes: 4 * 1024 ** 3 },++ ttl_ms: Math.min(timeoutMs + 600_000, 86_400_000),++ egress: 'public',++ requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['first-party', 'user-owned'] },++ };++ const idempotencyKey = `checks:${claimed.id}:${Number(claimed.attempts) + 1}`;++ try {++ const out = await db.tx(async (t) => {++ const made = await jobsStore.mint(t, body, { projectId: project, requester: 'service:checks', via: null, idempotencyKey, now: now() });++ if (made.created && events) await events.queued(t, made.row, {});++ await store.linkRunJob(t, claimed.id, made.row.id, now());++ return made;++ });++ return out.row;++ } catch (e) {++ await store.resetJobToWaiting(db, claimed.id, now()).catch(() => null);++ log.warn(`[Run] checks: could not queue ${plan.repo} ${claimed.display_name} (${e.code || e.message})`);++ return null;++ }++ }++++ /**++ * Advance one plan: skip what a failed/skipped need blocks, queue every whose needs succeeded (respecting++ * matrix max-parallel), and settle the plan when every job is complete.++ */++ async function queueReady(planId) {++ const plan = await store.getPlan(db, planId);++ if (!plan || plan.state !== 'active') return { queued: 0, skipped: 0 };++ let jobs = await store.getJobs(db, planId);++ // A check run whose creation failed at planning is retried here before the job is queued.++ if (jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {++ const { token } = await planContext(plan);++ if (token) {++ await ensureCheckRuns(plan, jobs, token);++ jobs = await store.getJobs(db, planId);++ }++ }++ const byKey = new Map(jobs.map((j) => [j.job_key, j]));++ const activeCounts = new Map();++ for (const j of jobs) if (j.matrix_group && (j.state === 'queued' || j.state === 'running')) activeCounts.set(j.matrix_group, (activeCounts.get(j.matrix_group) || 0) + 1);++ let queued = 0;++ let skipped = 0;++ for (const job of jobs) {++ if (job.state === 'completed' || job.run_job_id) continue;++ const needs = (Array.isArray(job.needs) ? job.needs : []).map((k) => byKey.get(k));++ if (needs.some((n) => !n)) {++ if (await skipJob(plan, job, 'a job it needs is not in the plan')) {++ byKey.set(job.job_key, { ...job, state: 'completed', conclusion: 'skipped' });++ skipped++;++ }++ continue;++ }++ if (needs.some((n) => n.state === 'completed' && n.conclusion !== 'success')) {++ if (await skipJob(plan, job, 'a job it needs did not succeed')) {++ byKey.set(job.job_key, { ...job, state: 'completed', conclusion: 'skipped' });++ skipped++;++ }++ continue;++ }++ if (needs.some((n) => n.state !== 'completed')) continue;++ if (job.matrix_group && job.max_parallel) {++ const active = activeCounts.get(job.matrix_group) || 0;++ if (active >= Number(job.max_parallel)) continue;++ activeCounts.set(job.matrix_group, active + 1);++ }++ const row = await queueJob(plan, job);++ if (row) queued++;++ }++ jobs = await store.getJobs(db, planId);++ if (jobs.length && jobs.every((j) => j.state === 'completed')) {++ const failed = jobs.some((j) => j.conclusion && j.conclusion !== 'success' && j.conclusion !== 'skipped');++ await store.setPlanState(db, planId, failed ? 'failed' : 'succeeded', now());++ }++ return { queued, skipped };++ }++++ /** Follow every Run job of a plan: start, retry a lost node once, finish with the result's conclusion. */++ async function syncRuns() {++ const rows = await store.unsettledRuns(db);++ let started = 0;++ let finished = 0;++ let retried = 0;++ for (const row of rows) {++ const plan = await store.getPlan(db, row.plan_id);++ if (!plan) continue;++ if (row.run_state === 'queued' || row.run_state === 'placed') continue;++ if (row.run_state === 'running') {++ if (row.check_state !== 'in_progress') {++ const { token } = await planContext(plan);++ if (token && row.check_run_id) {++ try {++ await github.updateCheckRun(token, {++ owner: plan.owner, repo: plan.repo.split('/')[1], checkRunId: row.check_run_id,++ status: 'in_progress', startedAt: new Date(row.run_started_ms || now()).toISOString(),++ detailsUrl: detailsUrlOf(row.run_job_id),++ });++ } catch (e) {++ log.warn(`[Run] checks: check run ${row.check_run_id} not marked in progress (${e.code || e.message})`);++ continue;++ }++ }++ await store.markRunning(db, row.id, now(), row.run_node_id);++ started++;++ }++ continue;++ }++ const result = parseJson(row.run_result);++ // A node that went away mid-job leaves no check result: retry once elsewhere before failing.++ const lostNode = !result.conclusion && result.ok === undefined && !Array.isArray(result.steps);++ if (lostNode && Number(row.attempts) < 2) {++ const reset = await store.resetJobToWaiting(db, row.id, now());++ if (reset && await queueJob(plan, reset)) retried++;++ continue;++ }++ const finishedAt = Number(row.run_finished_ms) || now();++ const startedAt = Number(row.run_started_ms) || (row.started_at != null ? Number(row.started_at) : null);++ const conclusion = conclusionOf(row, result);++ const ok = await finishJob(plan, row, {++ conclusion, summary: summaryOf(row, result, row),++ error: row.run_error_code || null,++ queueMs: startedAt ? Math.max(0, startedAt - Number(row.created_at)) : null,++ runMs: startedAt ? Math.max(0, finishedAt - startedAt) : null,++ startedAt, finishedAt, nodeId: row.run_node_id,++ });++ if (ok) finished++;++ }++ return { started, finished, retried };++ }++++ /** One scheduler pass: sweep old deliveries, follow the Run jobs, advance every active plan. */++ async function tick(at = now()) {++ if (!enabled) return { enabled: false, started: 0, finished: 0, retried: 0, queued: 0, skipped: 0, plans: 0 };++ await store.sweepDeliveries(db, at).catch(() => null);++ await store.sweepTokens(db, at).catch(() => null);++ await store.resetStaleQueuing(db, at).catch(() => null);++ const runs = await syncRuns();++ const plans = await store.listActivePlans(db);++ let queued = 0;++ let skipped = 0;++ for (const plan of plans) {++ try {++ const out = await queueReady(plan.id);++ queued += out.queued;++ skipped += out.skipped;++ } catch (e) {++ log.warn(`[Run] checks: plan ${plan.id} could not advance (${e.code || e.message})`);++ }++ }++ return { enabled: true, ...runs, queued, skipped, plans: plans.length };++ }++++ // ── webhook ────────────────────────────────────────────────────────────────++++ const allowedRepo = (payload) => {++ const full = payload && payload.repository && payload.repository.full_name;++ if (typeof full !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(full)) return false;++ const owner = full.split('/')[0].toLowerCase();++ if (!owners.includes(owner)) return false;++ const account = payload.installation && payload.installation.account && payload.installation.account.login;++ if (account && !owners.includes(String(account).toLowerCase())) return false;++ return true;++ };++++ async function rememberInstallation(payload, at) {++ const inst = payload && payload.installation;++ if (!inst || !inst.id) return;++ const account = inst.account || {};++ if (!owners.includes(String(account.login || '').toLowerCase())) return;++ await store.upsertInstallation(db, {++ installationId: Number(inst.id), login: account.login, accountId: account.id == null ? null : Number(account.id),++ accountType: account.type || null, suspendedAt: inst.suspended_at ? Date.parse(inst.suspended_at) : null,++ }, at);++ }++++ async function handleEvent(eventName, payload, deliveryId) {++ if (eventName === 'installation') {++ if (payload.action === 'deleted') {++ const inst = payload.installation;++ if (inst && inst.id) await db.maybe('DELETE FROM run_checks_installations WHERE installation_id = $1', [Number(inst.id)]);++ if (inst && inst.id) await store.deleteToken(db, Number(inst.id)).catch(() => null);++ } else {++ await rememberInstallation(payload, now());++ }++ return { installation: true };++ }++ if (eventName === 'workflow_job') {++ if (payload.action !== 'completed' || !allowedRepo(payload)) return {};++ const job = obj(payload.workflow_job);++ if (!SHA_RE.test(String(job.head_sha || '')) || !job.name || !job.conclusion) return {};++ await store.shadowActions(db, {++ repo: payload.repository.full_name, sha: job.head_sha,++ workflowName: job.workflow_name || '', jobName: String(job.name),++ conclusion: MAP_CONCLUSION[String(job.conclusion).toLowerCase()] || (CHECK_CONCLUSIONS.has(job.conclusion) ? job.conclusion : 'failure'),++ startedAt: job.started_at ? Date.parse(job.started_at) : null,++ finishedAt: job.completed_at ? Date.parse(job.completed_at) : null, at: now(),++ });++ return { shadow: true };++ }++ if (eventName === 'push') {++ if (!allowedRepo(payload)) return {};++ const branch = payload.repository.default_branch;++ if (payload.ref !== `refs/heads/${branch}`) return {};++ if (!SHA_RE.test(String(payload.after || ''))) return {};++ await rememberInstallation(payload, now());++ const [repoOwner, repoName] = payload.repository.full_name.split('/');++ return planForEvent({++ repo: payload.repository.full_name, owner: repoOwner, name: repoName,++ sha: payload.after, event: 'push', ref: payload.ref, branch,++ installationId: payload.installation && payload.installation.id, deliveryId,++ });++ }++ if (eventName === 'pull_request') {++ const action = payload.action;++ if (!['opened', 'synchronize', 'reopened'].includes(action) || !allowedRepo(payload)) return {};++ const pr = obj(payload.pull_request);++ const head = obj(pr.head);++ const headRepo = obj(head.repo);++ // A fork's workflow must never be read or run for a base repo (a stranger's commit is not our code).++ if (!headRepo.full_name || !allowedRepo({ repository: headRepo, installation: payload.installation })) return {};++ if (!SHA_RE.test(String(head.sha || ''))) return {};++ const branch = obj(pr.base).ref;++ await rememberInstallation(payload, now());++ const [repoOwner, repoName] = payload.repository.full_name.split('/');++ return planForEvent({++ repo: payload.repository.full_name, owner: repoOwner, name: repoName,++ sha: head.sha, event: 'pull_request', ref: `refs/pull/${pr.number}/merge`, branch, action,++ installationId: payload.installation && payload.installation.id, deliveryId,++ });++ }++ return {};++ }++++ async function webhook(req, res) {++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body || ''));++ const app = await appRow();++ if (!app || app.unreadable || !app.webhook_secret) return res.status(503).json({ error: 'checks.not_configured' });++ const signature = String(req.get('x-hub-signature-256') || '');++ const expected = `sha256=${crypto.createHmac('sha256', app.webhook_secret).update(raw).digest('hex')}`;++ if (!safeEqual(signature, expected)) return res.status(401).json({ error: 'invalid signature' });++ const deliveryId = String(req.get('x-github-delivery') || '').trim();++ if (!deliveryId) return res.status(400).json({ error: 'missing X-GitHub-Delivery' });++ const eventName = String(req.get('x-github-event') || '');++ let payload;++ try { payload = JSON.parse(raw.toString('utf8') || '{}'); } catch { return res.status(400).json({ error: 'invalid JSON' }); }++ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return res.status(400).json({ error: 'invalid payload' });++ const at = now();++ const fresh = await store.claimDelivery(db, { deliveryId, event: eventName, at, expiresAt: at + DELIVERY_TTL_MS });++ if (!fresh) return res.status(204).end();++ try {++ await handleEvent(eventName, payload, deliveryId);++ await store.sweepDeliveries(db, at).catch(() => null);++ return res.status(204).end();++ } catch (e) {++ log.warn(`[Run] checks: webhook ${eventName} failed (${e.code || e.message})`);++ await store.releaseDelivery(db, deliveryId).catch(() => null);++ return res.status(500).json({ error: 'checks.webhook_failed' });++ }++ }++++ // ── the owner page ─────────────────────────────────────────────────────────++++ function stateCookie(req) {++ const value = String((req.cookies && req.cookies.ov_checks_state) || '');++ const at = value.indexOf('.');++ if (at < 1) return { state: null, subject: null };++ return { state: value.slice(0, at), subject: value.slice(at + 1) };++ }++++ const admin = express.Router();++ admin.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ admin.get('/', ownerPage, async (req, res, next) => {++ try {++ const app = await appRow();++ const state = crypto.randomBytes(24).toString('base64url');++ const subject = String((req.user && (req.user.subject_id || req.user.sub)) || '');++ res.cookie('ov_checks_state', `${state}.${subject}`, {++ httpOnly: true, sameSite: 'lax', secure: Boolean(config.isProduction), path: '/admin/checks', maxAge: 10 * 60_000,++ });++ let installations = await store.listInstallations(db).catch(() => []);++ // Refresh the account list from GitHub when the App is readable; a failed read leaves the stored list.++ if (app && !app.unreadable && config.jobs.enabled) {++ try {++ const list = await github.listInstallations(app);++ for (const inst of list) {++ const account = obj(inst.account);++ if (!owners.includes(String(account.login || '').toLowerCase())) continue;++ await store.upsertInstallation(db, {++ installationId: Number(inst.id), login: account.login, accountId: account.id == null ? null : Number(account.id),++ accountType: account.type || null, suspendedAt: inst.suspended_at ? Date.parse(inst.suspended_at) : null,++ }, now());++ }++ installations = await store.listInstallations(db);++ } catch (e) { log.warn(`[Run] checks: installations could not be read (${e.code || e.message})`); }++ }++ const manifest = manifestFor({ baseUrl: config.baseUrl });++ const action = `${githubWeb}/organizations/${encodeURIComponent(org)}/settings/apps/new?state=${encodeURIComponent(state)}`;++ const warnings = [];++ if (!key) warnings.push('RUN_SECRETS_KEY is not set: the App cannot be stored.');++ if (!project) warnings.push('RUN_CHECKS_PROJECT is not set: no check is planned until it is.');++ if (app && app.unreadable) warnings.push('The stored App secrets cannot be opened with this RUN_SECRETS_KEY. Create the App again.');++ res.type('html').send(views.adminPage({++ app, installations, action, manifest: JSON.stringify(manifest), created: req.query.created === '1',++ warnings, org, keyConfigured: Boolean(key), projectConfigured: Boolean(project),++ installUrl: app && app.slug ? `${githubWeb}/apps/${encodeURIComponent(app.slug)}/installations/new` : null,++ }));++ } catch (e) { next(e); }++ });++++ admin.get('/created', ownerPage, async (req, res, next) => {++ try {++ if (!key) return res.status(503).type('html').send(views.errorPage({ title: 'Encryption is not installed', text: 'Set RUN_SECRETS_KEY and try again.' }));++ const cookie = stateCookie(req);++ const state = typeof req.query.state === 'string' ? req.query.state : '';++ const subject = String((req.user && (req.user.subject_id || req.user.sub)) || '');++ res.clearCookie('ov_checks_state', { path: '/admin/checks' });++ if (!cookie.state || !safeEqual(cookie.state, state) || cookie.subject !== subject) {++ return res.status(400).type('html').send(views.errorPage({ title: 'The state did not match', text: 'Start again from the checks page.' }));++ }++ if (req.query.error) {++ return res.status(400).type('html').send(views.errorPage({ title: 'GitHub did not create the App', text: String(req.query.error_description || req.query.error).slice(0, 300) }));++ }++ const code = typeof req.query.code === 'string' ? req.query.code : '';++ if (!code) return res.status(400).type('html').send(views.errorPage({ title: 'No code', text: 'GitHub returned no manifest code.' }));++ let converted;++ try { converted = await github.convertManifest(code); } catch (e) {++ log.warn(`[Run] checks: manifest conversion failed (${e.code || e.message})`);++ return res.status(502).type('html').send(views.errorPage({ title: 'GitHub did not return the App', text: 'The manifest code was not exchanged. Try creating the App again.' }));++ }++ const at = now();++ const appId = Number(converted.id);++ await store.putApp(db, {++ app_id: appId, slug: String(converted.slug || ''), name: String(converted.name || 'OpenVibe Checks'),++ html_url: converted.html_url || null,++ pem_enc: seal(key, converted.pem, `app:${appId}:pem`),++ webhook_secret_enc: seal(key, converted.webhook_secret, `app:${appId}:webhook_secret`),++ client_id: converted.client_id || null,++ client_secret_enc: converted.client_secret ? seal(key, converted.client_secret, `app:${appId}:client_secret`) : null,++ }, at);++ // A replacement App invalidates every token and account row of the old registration.++ await store.clearTokens(db).catch(() => null);++ await store.clearInstallations(db).catch(() => null);++ tokenCache.clear();++ res.redirect(303, '/admin/checks?created=1');++ } catch (e) { next(e); }++ });++++ // ── the owner's agreement query ────────────────────────────────────────────++++ async function agreementQuery(req, res) {++ const repo = typeof req.query.repo === 'string' ? req.query.repo.trim() : '';++ if (repo && !/^[^/\s]+\/[^/\s]+$/.test(repo)) return res.status(400).json({ error: 'repo must be owner/name' });++ const repos = await store.agreement(db, repo || null);++ res.json({ repos });++ }++++ /** GET /api/v1/checks/agreement: owner session only, like the page that shows it. */++ function agreement(req, res, next) {++ ownerApi(req, res, () => { agreementQuery(req, res).catch(next); });++ }++++ function start() {++ if (!enabled || !config.jobs.enabled || timer) return false;++ timer = setInterval(() => { tick().catch((e) => log.warn(`[Run] checks: tick failed (${e.code || e.message})`)); }, tickMs);++ timer.unref?.();++ return true;++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return {++ admin, webhook, agreement, tick, start, stop, planForEvent, queueReady, manifestFor,++ isOwner, detailsUrlOf, keyConfigured: Boolean(key), projectConfigured: Boolean(project),++ status: () => ({++ enabled, note: !key ? 'off: RUN_SECRETS_KEY is not set' : cfg.enabled === false ? 'off: RUN_CHECKS=off' : !project ? 'on, but RUN_CHECKS_PROJECT is not set so nothing is planned' : 'on',++ org, owners, tick_ms: tickMs, project_set: Boolean(project), running: Boolean(timer),++ }),++ deps: { config, db, github, store, owners, project, enabled },++ };++}++++module.exports = { createChecks, manifestFor, safeEqual };nnew file mode 100644@@@ -0,0 +1,208 @@++'use strict';++/**++ * The checks scheduler's rows (migrations/0020_checks.sql), row access only. A state change that also emits a++ * run.job.* event happens in the caller's transaction; the scheduler's own claim/complete updates are++ * conditional UPDATEs, so two ticks (or two processes) cannot queue the same workflow job twice or report a++ * check run twice.++ */++const json = (v) => (v == null ? null : JSON.stringify(v));++const num = (v) => (v == null ? null : Number(v));++++// ── App ──────────────────────────────────────────────────────────────────────++++const getApp = (db) => db.maybe('SELECT * FROM run_checks_app WHERE id = 1');++++async function putApp(db, row, at) {++ return db.maybe(++ `INSERT INTO run_checks_app (id, app_id, slug, name, html_url, pem_enc, webhook_secret_enc, client_id, client_secret_enc, created_at, updated_at)++ VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $9)++ ON CONFLICT (id) DO UPDATE SET app_id = EXCLUDED.app_id, slug = EXCLUDED.slug, name = EXCLUDED.name,++ html_url = EXCLUDED.html_url, pem_enc = EXCLUDED.pem_enc, webhook_secret_enc = EXCLUDED.webhook_secret_enc,++ client_id = EXCLUDED.client_id, client_secret_enc = EXCLUDED.client_secret_enc, updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [row.app_id, row.slug, row.name, row.html_url || null, row.pem_enc, row.webhook_secret_enc, row.client_id || null, row.client_secret_enc || null, at]);++}++++const deleteApp = (db) => db.maybe('DELETE FROM run_checks_app WHERE id = 1 RETURNING id');++++// ── installation tokens ─────────────────────────────────────────────────────++++const getToken = (db, installationId) => db.maybe('SELECT * FROM run_checks_tokens WHERE installation_id = $1', [installationId]);++++const putToken = (db, installationId, tokenEnc, expiresAt, at) => db.maybe(++ `INSERT INTO run_checks_tokens (installation_id, token_enc, expires_at, updated_at) VALUES ($1, $2, $3, $4)++ ON CONFLICT (installation_id) DO UPDATE SET token_enc = EXCLUDED.token_enc, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [installationId, tokenEnc, expiresAt, at]);++++const deleteToken = (db, installationId) => db.maybe('DELETE FROM run_checks_tokens WHERE installation_id = $1 RETURNING installation_id', [installationId]);++const clearTokens = (db) => db.maybe('DELETE FROM run_checks_tokens RETURNING installation_id');++const sweepTokens = (db, at) => db.maybe('DELETE FROM run_checks_tokens WHERE expires_at <= $1 RETURNING installation_id', [at]);++++// ── installations ───────────────────────────────────────────────────────────++++async function upsertInstallation(db, { installationId, login, accountId = null, accountType = null, suspendedAt = null }, at) {++ return db.maybe(++ `INSERT INTO run_checks_installations (installation_id, account_login, account_id, account_type, suspended_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $6)++ ON CONFLICT (installation_id) DO UPDATE SET account_login = EXCLUDED.account_login, account_id = EXCLUDED.account_id,++ account_type = EXCLUDED.account_type, suspended_at = EXCLUDED.suspended_at, updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [installationId, login, accountId, accountType, suspendedAt, at]);++}++++const listInstallations = (db) => db.many('SELECT * FROM run_checks_installations ORDER BY account_login, installation_id');++const clearInstallations = (db) => db.maybe('DELETE FROM run_checks_installations RETURNING installation_id');++++// ── deliveries ──────────────────────────────────────────────────────────────++++/** Claim one X-GitHub-Delivery. → true when it was new, false when it is a replay. */++async function claimDelivery(db, { deliveryId, event, at, expiresAt }) {++ const row = await db.maybe(++ `INSERT INTO run_checks_deliveries (delivery_id, event, received_at, expires_at) VALUES ($1, $2, $3, $4)++ ON CONFLICT (delivery_id) DO NOTHING RETURNING delivery_id`,++ [deliveryId, event, at, expiresAt]);++ return Boolean(row);++}++++const releaseDelivery = (db, deliveryId) => db.maybe('DELETE FROM run_checks_deliveries WHERE delivery_id = $1 RETURNING delivery_id', [deliveryId]);++++const sweepDeliveries = (db, at) => db.maybe('DELETE FROM run_checks_deliveries WHERE expires_at <= $1', [at]);++++// ── plans and jobs ──────────────────────────────────────────────────────────++++/** Insert a plan and its flat jobs in the caller's transaction. */++async function insertPlan(t, { plan, jobs }, at) {++ const row = await t.maybe(++ `INSERT INTO run_checks_plans (id, delivery_id, repo, repo_owner, sha, event, ref, branch, installation_id, workflow_path, workflow_name, state, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12, $12) RETURNING *`,++ [plan.id, plan.delivery_id || null, plan.repo, plan.owner, plan.sha, plan.event, plan.ref, plan.branch || null,++ plan.installation_id || null, plan.workflow_path, plan.workflow_name, at]);++ for (const job of jobs) {++ await t.maybe(++ `INSERT INTO run_checks_jobs (id, plan_id, job_key, job_name, display_name, workflow_path, workflow_job_key, stage, needs, matrix,++ matrix_group, max_parallel, timeout_minutes, check_state, state, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, 'queued', 'waiting', $14, $14)`,++ [job.id, plan.id, job.job_key, job.job_name, job.display_name, job.workflow_path, job.workflow_job_key, job.stage,++ JSON.stringify(job.needs || []), json(job.matrix), job.matrix_group || null, job.max_parallel || null,++ job.timeout_minutes, at]);++ }++ return row;++}++++const getPlan = (db, id) => db.maybe('SELECT *, repo_owner AS owner FROM run_checks_plans WHERE id = $1', [id]);++const getPlanByDelivery = (db, deliveryId) => db.many('SELECT *, repo_owner AS owner FROM run_checks_plans WHERE delivery_id = $1 ORDER BY created_at, id', [deliveryId]);++const listActivePlans = (db, limit = 50) => db.many(`SELECT *, repo_owner AS owner FROM run_checks_plans WHERE state = 'active' ORDER BY created_at, id LIMIT $1`, [limit]);++++const setPlanState = (db, id, state, at) => db.maybe(++ `UPDATE run_checks_plans SET state = $2, updated_at = $3 WHERE id = $1 AND state = 'active' RETURNING *`, [id, state, at]);++++const getJobs = (db, planId) => db.many('SELECT * FROM run_checks_jobs WHERE plan_id = $1 ORDER BY stage, job_key', [planId]);++const getJob = (db, id) => db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [id]);++++/** A tick claims a waiting job before it mints a Run job; two ticks can never mint two. */++const claimQueue = (db, id, at) => db.maybe(++ `UPDATE run_checks_jobs SET state = 'queuing', updated_at = $2 WHERE id = $1 AND state = 'waiting' AND run_job_id IS NULL RETURNING *`, [id, at]);++++const resetJobToWaiting = (db, id, at) => db.maybe(++ `UPDATE run_checks_jobs SET state = 'waiting', run_job_id = NULL, check_state = 'queued', started_at = NULL, updated_at = $2++ WHERE id = $1 AND state <> 'completed' RETURNING *`, [id, at]);++++/** Link the Run job a claim minted (in the same transaction as the mint when possible). */++const linkRunJob = (t, id, runJobId, at) => t.maybe(++ `UPDATE run_checks_jobs SET run_job_id = $2, state = 'queued', attempts = attempts + 1, queued_at = COALESCE(queued_at, $3), updated_at = $3++ WHERE id = $1 RETURNING *`, [id, runJobId, at]);++++/** A crash between claim and mint leaves `queuing` behind: a later tick may retry it. */++const resetStaleQueuing = (db, at, staleMs = 5 * 60_000) => db.many(++ `UPDATE run_checks_jobs SET state = 'waiting', updated_at = $1 WHERE state = 'queuing' AND updated_at < $2 RETURNING id`, [at, at - staleMs]);++++const markRunning = (db, id, at, nodeId = null) => db.maybe(++ `UPDATE run_checks_jobs SET state = 'running', check_state = 'in_progress', started_at = COALESCE(started_at, $2), node_id = COALESCE($3, node_id), updated_at = $2++ WHERE id = $1 AND state IN ('queued', 'running') RETURNING *`, [id, at, nodeId]);++++const setCheckRun = (db, id, checkRunId, at) => db.maybe(++ `UPDATE run_checks_jobs SET check_run_id = $2, updated_at = $3 WHERE id = $1 RETURNING *`, [id, checkRunId, at]);++++const bumpReportError = (db, id, at) => db.maybe(++ `UPDATE run_checks_jobs SET report_errors = report_errors + 1, updated_at = $2 WHERE id = $1 RETURNING *`, [id, at]);++++/** Complete once: the condition makes a second tick (or a second process) a quiet no-op. */++const completeJob = (db, id, { conclusion, summary = null, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null, at }) => db.maybe(++ `UPDATE run_checks_jobs SET state = 'completed', check_state = 'completed', conclusion = $2, summary = $3, error = $4,++ queue_ms = COALESCE($5, queue_ms), run_ms = COALESCE($6, run_ms), started_at = COALESCE(started_at, $7),++ finished_at = $8, node_id = COALESCE($9, node_id), updated_at = $8++ WHERE id = $1 AND state <> 'completed' RETURNING *`,++ [id, conclusion, summary, error, queueMs, runMs, startedAt, finishedAt, nodeId]);++++/** The Run jobs whose check run has not been settled yet, with the Run row beside them. */++const unsettledRuns = (db) => db.many(++ `SELECT cj.*, rj.state AS run_state, rj.result AS run_result, rj.started_ms AS run_started_ms, rj.finished_ms AS run_finished_ms,++ rj.node_id AS run_node_id, rj.exit_reason AS run_exit_reason, rj.error_code AS run_error_code++ FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id++ WHERE cj.state IN ('queued', 'running') AND cj.check_state <> 'completed'++ ORDER BY cj.updated_at, cj.id LIMIT 500`);++++// ── shadow ──────────────────────────────────────────────────────────────────++++async function shadowNode(db, { repo, sha, workflowName = '', jobName, checkRunId = null, conclusion, queueMs = null, runMs = null, startedAt = null, finishedAt = null, at }) {++ const hit = await db.maybe(++ `UPDATE run_checks_shadow SET node_conclusion = $5, check_run_id = COALESCE($6, check_run_id), queue_ms = $7, run_ms = $8,++ node_started_at = $9, node_finished_at = $10, updated_at = $11++ WHERE repo = $1 AND sha = $2 AND job_name = $3 AND ($4 = '' OR workflow_name = '' OR workflow_name = $4)++ RETURNING *`,++ [repo, sha, jobName, workflowName || '', conclusion, checkRunId, queueMs, runMs, startedAt, finishedAt, at]);++ if (hit) return hit;++ return db.maybe(++ `INSERT INTO run_checks_shadow (repo, sha, workflow_name, job_name, check_run_id, node_conclusion, queue_ms, run_ms, node_started_at, node_finished_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING *`,++ [repo, sha, workflowName || '', jobName, checkRunId, conclusion, queueMs, runMs, startedAt, finishedAt, at]);++}++++async function shadowActions(db, { repo, sha, workflowName = '', jobName, conclusion, startedAt = null, finishedAt = null, at }) {++ const hit = await db.maybe(++ `UPDATE run_checks_shadow SET actions_conclusion = $5, actions_started_at = $6, actions_finished_at = $7, updated_at = $8,++ workflow_name = CASE WHEN workflow_name = '' THEN $4 ELSE workflow_name END++ WHERE repo = $1 AND sha = $2 AND job_name = $3 AND ($4 = '' OR workflow_name = '' OR workflow_name = $4)++ RETURNING *`,++ [repo, sha, jobName, workflowName || '', conclusion, startedAt, finishedAt, at]);++ if (hit) return hit;++ return db.maybe(++ `INSERT INTO run_checks_shadow (repo, sha, workflow_name, job_name, actions_conclusion, actions_started_at, actions_finished_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING *`,++ [repo, sha, workflowName || '', jobName, conclusion, startedAt, finishedAt, at]);++}++++/** Agreement per repo: paired rows, agreed rows, and the node's average queue/run time. */++async function agreement(db, repo = null) {++ const rows = await db.many(++ `SELECT repo,++ COUNT(*)::int AS total,++ COUNT(*) FILTER (WHERE node_conclusion IS NOT NULL AND actions_conclusion IS NOT NULL)::int AS paired,++ COUNT(*) FILTER (WHERE node_conclusion IS NOT NULL AND actions_conclusion IS NOT NULL AND node_conclusion = actions_conclusion)::int AS agreed,++ AVG(queue_ms) AS queue_avg, AVG(run_ms) AS run_avg++ FROM run_checks_shadow++ WHERE ($1::text IS NULL OR repo = $1)++ GROUP BY repo ORDER BY repo`,++ [repo || null]);++ return rows.map((r) => ({++ repo: r.repo,++ checks: Number(r.total || 0),++ paired: Number(r.paired || 0),++ agreed: Number(r.agreed || 0),++ agreement: Number(r.paired || 0) ? Number(r.agreed || 0) / Number(r.paired) : null,++ queue_ms: num(r.queue_avg),++ run_ms: num(r.run_avg),++ }));++}++++module.exports = {++ getApp, putApp, deleteApp,++ getToken, putToken, deleteToken, clearTokens, sweepTokens,++ upsertInstallation, listInstallations, clearInstallations,++ claimDelivery, releaseDelivery, sweepDeliveries,++ insertPlan, getPlan, getPlanByDelivery, listActivePlans, setPlanState,++ getJobs, getJob, claimQueue, resetJobToWaiting, linkRunJob, resetStaleQueuing,++ markRunning, setCheckRun, bumpReportError, completeJob, unsettledRuns,++ shadowNode, shadowActions, agreement,++};nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * The owner-only checks page. It shows the App's state, never one of its secrets: the manifest is public++ * configuration, and the id, slug, account names and whether the App exists are the whole answer. No PEM,++ * webhook secret, client secret or installation token is interpolated here.++ */++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const PAGE = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)} · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:960px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}a{color:light-dark(#075a85,#87d6ff)}code{overflow-wrap:anywhere}.badge{display:inline-block;padding:2px 10px;border-radius:999px;border:1px solid #70809080;font-size:.9rem}.warn{padding:12px 16px;border-radius:8px;background:#b4530920;border:1px solid #b4530980}.ok{padding:12px 16px;border-radius:8px;background:#15803d20;border:1px solid #15803d80}button{font:inherit;min-height:44px;padding:8px 16px;border-radius:8px;border:1px solid #70809080;cursor:pointer}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:8px 10px;border-bottom:1px solid #70809060;white-space:nowrap}ul{padding-left:22px}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style></head><body><main>${body}</main></body></html>`;++++function errorPage({ title = 'Checks', text, back = '/admin/checks' } = {}) {++ return PAGE(title, `<h1>${esc(title)}</h1><section><p>${esc(text || 'That did not work.')}</p><p><a href="${esc(back)}">Back to checks</a></p></section>`);++}++++function adminPage({ app = null, installations = [], action, manifest, created = false, warnings = [], installUrl = null, keyConfigured = true, projectConfigured = true, org = 'OpenVibers' } = {}) {++ const state = app ? (installations.length ? 'installed' : 'created') : 'none';++ const rows = installations.map((i) => `<tr><td>${esc(i.account_login)}</td><td>${esc(i.account_type || '')}</td><td>${esc(i.installation_id)}</td><td>${i.suspended_at ? 'suspended' : 'active'}</td></tr>`).join('');++ return PAGE('GitHub checks', `++<h1>GitHub checks</h1>++<p>Checks on shared nodes: a workflow job from an <strong>${esc(org)}</strong> repository is planned here and run as a++Run job on a node, with a GitHub check run beside it. The App is the only way Run reads workflows and reports its work.</p>++<section>++ <p>App state: <span class="badge">${esc(state)}</span>${app ? ` · <strong>${esc(app.name)}</strong> (id ${esc(app.app_id)}, slug ${esc(app.slug)})` : ''}</p>++ ${created ? '<p class="ok">The App was created and its key, webhook secret and client secret are stored encrypted. Install it on the organization next.</p>' : ''}++ ${warnings.map((w) => `<p class="warn">${esc(w)}</p>`).join('')}++ ${!keyConfigured ? '<p>Set <code>RUN_SECRETS_KEY</code> (32 bytes, base64 or 64 hex) before creating the App: without it Run cannot store the App\'s secrets.</p>' : ''}++ ${!projectConfigured ? '<p>Set <code>RUN_CHECKS_PROJECT</code> to the project check jobs belong to; no check is planned until it is set.</p>' : ''}++</section>++${app ? `<section>++ <h2>Installed on</h2>++ ${rows ? `<table><tr><th>Account</th><th>Type</th><th>Installation</th><th>State</th></tr>${rows}</table>` : '<p>Not installed on an account yet.</p>'}++ ${installUrl ? `<p><a href="${esc(installUrl)}">Install ${esc(app.name)} on ${esc(org)}</a></p>` : ''}++ <p>Webhook: <code>/webhooks/github</code> · callback: <code>/admin/checks/created</code></p>++</section>` : ''}++<section>++ <h2>${app ? 'Replace the GitHub App' : 'Create the GitHub App'}</h2>++ <p>GitHub registers the App from this manifest and returns its id, private key and webhook secret once. Those are stored++ encrypted; they are never shown here, logged or sent to a node.</p>++ <form method="post" action="${esc(action)}">++ <input type="hidden" name="manifest" value="${esc(manifest)}">++ <button type="submit"${keyConfigured ? '' : ' disabled'}>${app ? 'Create a replacement App' : 'Create the GitHub App'}</button>++ </form>++</section>`);++}++++module.exports = { adminPage, errorPage, PAGE, esc };@@@ -138,6 +138,27 @@ function loadConfig(env = process.env) {maxPerTick: Math.max(1, int(env.RUN_DISPATCH_MAX_PER_TICK, 200)),},++ // The checks scheduler (research-2026-11-13/shared-node-checks.md): the GitHub App made by the manifest++ // flow, the webhook and the workflow jobs Run plans from it. RUN_SECRETS_KEY seals the App's private key,++ // webhook secret and installation tokens at rest (server/checks/crypto.js); without it the page and the++ // webhook stay off. RUN_CHECKS_PROJECT is the project a check job belongs to. RUN_CHECKS=off turns the++ // scheduler off; the tick only runs when the background jobs are on (RUN_JOBS).++ checks: {++ enabled: env.RUN_CHECKS !== 'off',++ secretsKey: String(env.RUN_SECRETS_KEY || ''),++ project: String(env.RUN_CHECKS_PROJECT || '').trim(),++ owners: String(env.RUN_CHECKS_OWNERS || 'OpenVibers').split(',').map((s) => s.trim()).filter(Boolean),++ org: env.RUN_CHECKS_ORG || 'OpenVibers',++ // The network owner: their subject (RUN_CHECKS_OWNER, else the compute owner) or the Network owner's++ // username (the same OWNER_USERNAME Network's owner guard uses).++ owner: env.RUN_CHECKS_OWNER || env.RUN_COMPUTE_OWNER || '',++ ownerUsername: env.OWNER_USERNAME || 'goosely',++ githubApi: trim(env.RUN_CHECKS_GITHUB_API || 'https://api.github.com'),++ githubWeb: trim(env.RUN_CHECKS_GITHUB_WEB || 'https://github.com'),++ tickMs: Math.max(500, int(env.RUN_CHECKS_TICK_MS, 5000)),++ requestTimeoutMs: Math.max(1000, int(env.RUN_CHECKS_TIMEOUT_MS, 8000)),++ },++// Placement A1: Bench's quality priors, cached an hour, used by place() when the option is a model Bench has// seen. Off in tests unless asked for, so a unit test never reaches the real Bench.bench: {@@@ -39,7 +39,7 @@ async function main() {const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;const app = createApp({ config, db, valkey, registry, accountData, accountSend });await app.locals.initComputeSettings();-- const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker } = app.locals;++ const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker, checks } = app.locals;keys.start();const ticketKey = tickets.start();@@@ -52,6 +52,8 @@ async function main() {// Run sessions (server/sessions/): place, watch and meter the open ones. Off without the Bot token.sessions.start();broker.start();++ // The checks scheduler (server/checks/): follow the Run jobs it planned and report their check runs.++ checks.start();}let accountSubscription = null;@@@ -90,6 +92,7 @@ async function main() {() => keys.stop(),() => tickets.stop(),() => dispatch.stop(),++ () => checks.stop(),() => broker.stop(),() => sessions.stop(),() => stream.close(),nnew file mode 100644@@@ -0,0 +1,516 @@++'use strict';++/**++ * The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2). Every GitHub call goes to a++ * stub through an injected fetch; no test reaches github.com. The signature, delivery dedupe, allow-list,++ * planner, check-run calls, manifest exchange + sealed secrets, owner gate, agreement query and the++ * lost-node retry are exercised end to end through the app's real routes.++ *++ * node test/checks.test.js++ */++const assert = require('assert');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++const { planWorkflows, eventMatches, matrixCombinations } = require('../server/checks/planner');++const { keyOf, seal, open } = require('../server/checks/crypto');++++const SECRETS_KEY = crypto.randomBytes(32).toString('base64');++const PROJECT = ids.newId('project');++const SHA = 'a'.repeat(40);++const WEBHOOK_SECRET = 'test-webhook-secret';++const APP_ID = 4242;++++const jsonResponse = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++++/** A GitHub API stub: workflows at a SHA, check runs, the installation token and the manifest conversion. */++function startGitHubStub() {++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const conversion = {++ id: APP_ID, slug: 'openvibe-checks', name: 'OpenVibe Checks', html_url: 'https://github.test/apps/openvibe-checks',++ pem, webhook_secret: WEBHOOK_SECRET, client_id: 'Iv1.checks', client_secret: 'test-client-secret-not-real',++ };++ const files = new Map();++ const calls = [];++ const checkRuns = new Map();++ let nextCheckRun = 5000;++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(String(url));++ if (u.origin !== 'https://github.test') return globalThis.fetch(url, init);++ const method = init.method || 'GET';++ let body = null;++ if (init.body) { try { body = JSON.parse(init.body); } catch { body = String(init.body); } }++ calls.push({ method, path: u.pathname + u.search, body, authorization: String((init.headers && init.headers.Authorization) || '') });++++ if (method === 'POST' && u.pathname === '/app-manifests/code-ok/conversions') return jsonResponse(200, conversion);++ if (method === 'POST' && u.pathname === '/app-manifests/code-bad/conversions') return jsonResponse(500, { message: 'boom' });++ if (method === 'GET' && u.pathname === '/app/installations') return jsonResponse(200, [{ id: 42, account: { login: 'OpenVibers', id: 7, type: 'Organization' }, suspended_at: null }]);++ if (method === 'POST' && u.pathname === '/app/installations/42/access_tokens') return jsonResponse(201, { token: 'test-installation-token-not-real', expires_at: new Date(Date.now() + 3600e3).toISOString() });++ if (method === 'GET' && u.pathname === '/repos/OpenVibers/demo/contents/.github/workflows') {++ return jsonResponse(200, [...files.keys()].map((path) => ({ type: 'file', name: path.split('/').pop(), path, sha: 'b'.repeat(40) })));++ }++ if (method === 'GET' && u.pathname.startsWith('/repos/OpenVibers/demo/contents/')) {++ const path = decodeURIComponent(u.pathname.slice('/repos/OpenVibers/demo/contents/'.length));++ const source = files.get(path);++ if (source == null) return jsonResponse(404, { message: 'Not Found' });++ return new Response(source, { status: 200, headers: { 'Content-Type': 'text/plain' } });++ }++ if (method === 'POST' && u.pathname === '/repos/OpenVibers/demo/check-runs') {++ const id = nextCheckRun++;++ checkRuns.set(id, { ...(body || {}) });++ return jsonResponse(201, { id, html_url: `https://github.test/OpenVibers/demo/runs/${id}` });++ }++ if (method === 'PATCH' && /^\/repos\/OpenVibers\/demo\/check-runs\/\d+$/.test(u.pathname)) {++ const id = Number(u.pathname.split('/').pop());++ checkRuns.set(id, { ...(checkRuns.get(id) || {}), ...(body || {}) });++ return jsonResponse(200, { id, ...(checkRuns.get(id) || {}) });++ }++ return jsonResponse(404, { message: `stub has no route for ${method} ${u.pathname}` });++ };++ return { fetchImpl, calls, checkRuns, files, conversion, setFile: (path, source) => files.set(path, source) };++}++++async function bootChecks(gh, extraEnv = {}) {++ const t = await boot({++ env: {++ RUN_JOBS: 'on',++ RUN_SECRETS_KEY: SECRETS_KEY,++ RUN_CHECKS_PROJECT: PROJECT,++ RUN_CHECKS_GITHUB_API: 'https://github.test',++ RUN_CHECKS_GITHUB_WEB: 'https://github.test',++ OWNER_USERNAME: 'goosely',++ ...extraEnv,++ },++ appOpts: { fetchImpl: gh.fetchImpl },++ });++ return t;++}++++async function seedApp(t, gh) {++ const key = keyOf(SECRETS_KEY);++ await t.db.maybe(++ `INSERT INTO run_checks_app (id, app_id, slug, name, html_url, pem_enc, webhook_secret_enc, client_id, client_secret_enc, created_at, updated_at)++ VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $9)`,++ [gh.conversion.id, gh.conversion.slug, gh.conversion.name, gh.conversion.html_url,++ seal(key, gh.conversion.pem, `app:${gh.conversion.id}:pem`),++ seal(key, WEBHOOK_SECRET, `app:${gh.conversion.id}:webhook_secret`),++ gh.conversion.client_id, seal(key, gh.conversion.client_secret, `app:${gh.conversion.id}:client_secret`), Date.now()]);++}++++function userCookie(t, { username = 'goosely', subject = ids.newId('user') } = {}) {++ const token = jwt.sign({ sub: String(Date.now()), subject_id: subject, username, display_name: username, role: 'user' },++ t.network.privatePem, { algorithm: 'RS256', issuer: t.network.issuer, audience: 'openvibe.network', expiresIn: '1h' });++ return `ov_token=${token}`;++}++++const setCookies = (res) => (typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean));++++async function postWebhook(t, event, payload, { delivery = crypto.randomUUID(), secret = WEBHOOK_SECRET, signature = true } = {}) {++ const raw = Buffer.from(JSON.stringify(payload));++ const headers = { 'Content-Type': 'application/json', 'X-GitHub-Event': event, 'X-GitHub-Delivery': delivery };++ if (signature) headers['X-Hub-Signature-256'] = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;++ const res = await fetch(`${t.base}/webhooks/github`, { method: 'POST', headers, body: raw });++ return { status: res.status, text: await res.text(), headers: res.headers };++}++++const repoOf = (full = 'OpenVibers/demo') => {++ const [owner, name] = full.split('/');++ return { full_name: full, name, owner: { login: owner, id: 7, type: 'Organization' }, default_branch: 'main', clone_url: `https://github.com/${full}.git`, private: false };++};++const installationOf = () => ({ id: 42, account: { login: 'OpenVibers', id: 7, type: 'Organization' }, suspended_at: null });++const pushPayload = (over = {}) => ({ ref: 'refs/heads/main', after: SHA, repository: repoOf(), installation: installationOf(), ...over });++++const CI = `++name: CI++on:++ push:++ branches: [main]++jobs:++ build:++ runs-on: ubuntu-latest++ name: Build++ test:++ needs: build++ runs-on: ubuntu-latest++ name: Test++`;++++async function main() {++ console.log('checks scheduler');++++ await check('planner: on branches, needs stages, matrix include/exclude, reusable sub-play', async () => {++ assert.equal(eventMatches('push', { event: 'pull_request', branch: 'main' }), false);++ assert.equal(eventMatches({ push: { branches: ['main'] } }, { event: 'push', branch: 'main' }), true);++ assert.equal(eventMatches({ push: { branches: ['release/**'] } }, { event: 'push', branch: 'main' }), false);++ const combos = matrixCombinations({ matrix: { node: [18, 20], os: ['ubuntu-latest'], include: [{ node: 22, experimental: true }, { os: 'macos-latest', node: 18 }], exclude: [{ node: 20 }] } });++ assert.equal(combos.length, 3);++ assert.deepEqual(combos.map((c) => c.node), [18, 22, 18]);++ assert.deepEqual(combos[2], { os: 'macos-latest', node: 18 });++++ const workflows = [++ { path: '.github/workflows/ci.yml', source: CI },++ { path: '.github/workflows/pr.yml', source: 'on: pull_request\njobs:\n pr: {runs-on: ubuntu-latest}\n' },++ ];++ const planned = await planWorkflows({ workflows, event: 'push', branch: 'main' });++ assert.equal(planned.errors.length, 0);++ assert.equal(planned.workflows.length, 1);++ const byKey = Object.fromEntries(planned.workflows[0].jobs.map((j) => [j.key, j]));++ assert.equal(byKey.build.stage, 0);++ assert.equal(byKey.test.stage, 1);++ assert.deepEqual(byKey.test.needs, ['build']);++++ const reusable = [{ path: '.github/workflows/ci.yml', source: 'on: push\njobs:\n setup: {runs-on: ubuntu-latest}\n call:\n needs: setup\n uses: ./.github/workflows/reuse.yml\n after:\n needs: call\n' }];++ const sub = await planWorkflows({++ workflows: reusable, event: 'push', branch: 'main',++ readWorkflow: async () => 'on: workflow_call\njobs:\n build: {runs-on: ubuntu-latest}\n test:\n needs: build\n',++ });++ const jobs = Object.fromEntries(sub.workflows[0].jobs.map((j) => [j.key, j]));++ assert.equal(jobs.setup.stage, 0);++ assert.equal(jobs['call/build'].stage, 1);++ assert.deepEqual(jobs['call/build'].needs, ['setup']);++ assert.equal(jobs['call/test'].stage, 2);++ assert.equal(jobs['call/test'].display_name, 'call / test');++ assert.deepEqual(jobs.after.needs.sort(), ['call/build', 'call/test']);++ });++++ await check('owner gate on /admin/checks', async () => {++ const gh = startGitHubStub();++ const t = await bootChecks(gh);++ try {++ const anon = await fetch(`${t.base}/admin/checks`, { redirect: 'manual' });++ assert.equal(anon.status, 302);++ assert.match(anon.headers.get('location'), /^\/auth\/login/);++ const other = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: userCookie(t, { username: 'not-the-owner' }) } });++ assert.equal(other.status, 403);++ const owner = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: userCookie(t) } });++ assert.equal(owner.status, 200);++ const bearer = await fetch(`${t.base}/admin/checks`, { headers: { Authorization: `Bearer ${t.network.signUser({ username: 'goosely' })}` } });++ assert.equal(bearer.status, 200, 'the owner may also present a Network user token');++ const html = await owner.text();++ assert.match(html, /Create the GitHub App/);++ assert.match(html, /method="post"/);++ assert.match(html, /organizations\/OpenVibers\/settings\/apps\/new\?state=/);++ const manifest = JSON.parse(html.match(/name="manifest" value="([^"]*)"/)[1].replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));++ assert.equal(manifest.name, 'OpenVibe Checks');++ assert.deepEqual(manifest.default_permissions, { checks: 'write', statuses: 'write', contents: 'read', pull_requests: 'read', metadata: 'read', actions: 'read' });++ assert.deepEqual(manifest.default_events, ['push', 'pull_request', 'check_suite', 'workflow_job']);++ assert.equal(manifest.public, false);++ assert.equal(manifest.hook_attributes.url, `${t.config.baseUrl}/webhooks/github`);++ } finally { await t.close(); }++ });++++ await check('manifest exchange: state checked, secrets sealed, raw PEM never in a row or the page', async () => {++ const gh = startGitHubStub();++ const t = await bootChecks(gh);++ try {++ const cookie = userCookie(t);++ const page = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: cookie } });++ assert.equal(page.status, 200);++ const html = await page.text();++ const state = new URL(html.match(/action="([^"]+)"/)[1].replace(/&/g, '&')).searchParams.get('state');++ const stateCookie = setCookies(page).find((c) => c.startsWith('ov_checks_state='));++ assert.ok(stateCookie);++ const created = await fetch(`${t.base}/admin/checks/created?code=code-ok&state=${encodeURIComponent(state)}`, {++ headers: { Cookie: `${cookie}; ${stateCookie.split(';')[0]}` }, redirect: 'manual',++ });++ assert.equal(created.status, 303);++ assert.equal(created.headers.get('location'), '/admin/checks?created=1');++++ const row = await t.db.maybe('SELECT * FROM run_checks_app WHERE id = 1');++ assert.equal(Number(row.app_id), APP_ID);++ assert.equal(row.slug, 'openvibe-checks');++ assert.ok(row.pem_enc && !row.pem_enc.includes(gh.conversion.pem));++ assert.ok(!JSON.stringify(row).includes(gh.conversion.pem));++ assert.ok(!JSON.stringify(row).includes('test-client-secret-not-real'));++ const key = keyOf(SECRETS_KEY);++ assert.equal(open(key, row.pem_enc, `app:${APP_ID}:pem`), gh.conversion.pem);++ assert.equal(open(key, row.webhook_secret_enc, `app:${APP_ID}:webhook_secret`), WEBHOOK_SECRET);++ assert.equal(open(key, row.client_secret_enc, `app:${APP_ID}:client_secret`), 'test-client-secret-not-real');++++ const after = await fetch(`${t.base}/admin/checks?created=1`, { headers: { Cookie: cookie } });++ const afterHtml = await after.text();++ assert.match(afterHtml, /installed|created/);++ assert.match(afterHtml, /Install OpenVibe Checks on OpenVibers/);++ assert.ok(!afterHtml.includes(gh.conversion.pem));++ assert.ok(!afterHtml.includes('test-client-secret-not-real'));++ assert.ok(!afterHtml.includes(WEBHOOK_SECRET));++ } finally { await t.close(); }++ });++++ await check('webhook: signature good/bad/missing and delivery dedupe', async () => {++ const gh = startGitHubStub();++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ const delivery = crypto.randomUUID();++ const good = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery });++ assert.equal(good.status, 204);++ const again = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery });++ assert.equal(again.status, 204);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_deliveries'), 1);++ const bad = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery: crypto.randomUUID(), secret: 'wrong' });++ assert.equal(bad.status, 401);++ const missing = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery: crypto.randomUUID(), signature: false });++ assert.equal(missing.status, 401);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_deliveries'), 1);++ } finally { await t.close(); }++ });++++ await check('webhook: a repository outside OpenVibers is ignored (no read, no plan)', async () => {++ const gh = startGitHubStub();++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ const payload = pushPayload({ repository: repoOf('SomeoneElse/demo') });++ const res = await postWebhook(t, 'push', payload, { delivery: crypto.randomUUID() });++ assert.equal(res.status, 204);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 0);++ assert.equal(gh.calls.filter((c) => c.path.includes('/contents/')).length, 0);++ } finally { await t.close(); }++ });++++ await check('push plans the workflow, queues stage 0 and creates a queued check run per job', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ const res = await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ assert.equal(res.status, 204);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 1);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_jobs'), 2);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1);++ const created = gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');++ assert.equal(created.length, 2);++ assert.deepEqual(created.map((c) => c.body.name).sort(), ['openvibe / Build', 'openvibe / Test']);++ assert.ok(created.every((c) => c.body.status === 'queued' && c.body.head_sha === SHA));++ const tokenCalls = gh.calls.filter((c) => c.path === '/app/installations/42/access_tokens');++ assert.equal(tokenCalls.length, 1, 'installation token is cached');++ assert.ok(tokenCalls[0].authorization.startsWith('Bearer eyJ'));++ assert.ok(gh.calls.filter((c) => c.path.includes('/contents/')).every((c) => c.authorization === 'Bearer test-installation-token-not-real'));++ const tokenRow = await t.db.maybe('SELECT * FROM run_checks_tokens WHERE installation_id = 42');++ assert.ok(tokenRow, 'the installation token is cached in the database');++ assert.ok(!tokenRow.token_enc.includes('test-installation-token-not-real'));++ assert.equal(open(keyOf(SECRETS_KEY), tokenRow.token_enc, 'installation:42'), 'test-installation-token-not-real');++++ const run = await t.db.maybe('SELECT * FROM run_jobs');++ assert.equal(run.class, 'code');++ assert.equal(run.job.artifact.name, 'openvibe-codes');++ assert.equal(run.job.artifact.version, '1.0.0');++ assert.equal(run.job.args.kind, 'check');++ assert.equal(run.job.args.repo, 'https://github.com/OpenVibers/demo.git');++ assert.equal(run.job.args.sha, SHA);++ assert.equal(run.job.args.workflow, '.github/workflows/ci.yml');++ assert.equal(run.job.args.job, 'build');++ assert.equal(run.job.args.event, 'push');++ assert.equal(run.job.args.ref, 'refs/heads/main');++ assert.equal(run.job.net, 'public');++ assert.deepEqual(run.job.requirements.trust, ['first-party', 'user-owned']);++ assert.equal(run.project_id, PROJECT);++ assert.equal(run.requester, 'service:checks');++ const waiting = await t.db.maybe("SELECT * FROM run_checks_jobs WHERE state = 'waiting'");++ assert.equal(waiting.display_name, 'Test');++ assert.deepEqual(waiting.needs, ['build']);++ } finally { await t.close(); }++ });++++ await check('check runs: in_progress when the node starts, completed with the steps and details_url', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ assert.equal((await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() })).status, 204);++ const build = await t.db.maybe("SELECT cj.id AS check_job_id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");++ await t.db.maybe("UPDATE run_jobs SET state = 'running', started_ms = $2, node_id = 'dev_node_1' WHERE id = $1", [build.run_job_id, Date.now()]);++ await t.checks.tick();++ let check = gh.checkRuns.get(Number(build.check_run_id));++ assert.equal(check.status, 'in_progress');++ assert.ok(check.started_at);++ assert.match(check.details_url, new RegExp(`/api/v1/jobs/${build.run_job_id}$`));++++ const finished = Date.now();++ await t.db.maybe(++ "UPDATE run_jobs SET state = 'succeeded', finished_ms = $2, wall_ms = 1200, result = $3::jsonb WHERE id = $1",++ [build.run_job_id, finished, JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }], ms: 1200 })]);++ await t.checks.tick();++ check = gh.checkRuns.get(Number(build.check_run_id));++ assert.equal(check.status, 'completed');++ assert.equal(check.conclusion, 'success');++ assert.match(check.output.summary, /ok/);++ assert.match(check.details_url, new RegExp(`/api/v1/jobs/${build.run_job_id}$`));++ const row = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [build.check_job_id]);++ assert.equal(row.state, 'completed');++ assert.equal(row.conclusion, 'success');++ assert.ok(Number(row.queue_ms) >= 0 && Number(row.run_ms) >= 0);++ const next = await t.db.maybe("SELECT cj.*, rj.id AS run_job_id FROM run_checks_jobs cj LEFT JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Test'");++ assert.ok(next.run_job_id, 'the later stage is queued when its need succeeds');++ const shadow = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE job_name = 'Build'");++ assert.equal(shadow.node_conclusion, 'success');++ } finally { await t.close(); }++ });++++ await check('shadow: workflow_job completed records the Actions conclusion; agreement is per repo and owner-only', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ const build = await t.db.maybe("SELECT cj.id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");++ const started = Date.now() - 5000;++ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', started_ms = $2, finished_ms = $3, wall_ms = 4000, result = $4::jsonb WHERE id = $1",++ [build.run_job_id, started, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }] })]);++ await t.checks.tick();++++ const actions = await postWebhook(t, 'workflow_job', {++ action: 'completed', repository: repoOf(), installation: installationOf(),++ workflow_job: { id: 9001, name: 'Build', head_sha: SHA, conclusion: 'success', workflow_name: 'CI', started_at: new Date(started).toISOString(), completed_at: new Date().toISOString() },++ }, { delivery: crypto.randomUUID() });++ assert.equal(actions.status, 204);++ const pair = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE repo = 'OpenVibers/demo' AND sha = $1 AND job_name = 'Build'", [SHA]);++ assert.equal(pair.actions_conclusion, 'success');++ assert.equal(pair.node_conclusion, 'success');++++ const owner = userCookie(t);++ const anon = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`);++ assert.equal(anon.status, 401);++ const other = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`, { headers: { Cookie: userCookie(t, { username: 'ada' }) } });++ assert.equal(other.status, 403);++ const agreement = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`, { headers: { Cookie: owner } });++ assert.equal(agreement.status, 200);++ const body = await agreement.json();++ assert.equal(body.repos.length, 1);++ assert.equal(body.repos[0].repo, 'OpenVibers/demo');++ assert.equal(body.repos[0].paired, 1);++ assert.equal(body.repos[0].agreement, 1);++ assert.ok(body.repos[0].run_ms >= 0);++ } finally { await t.close(); }++ });++++ await check('pull_request opened plans at the head SHA; closed and a fork head do not', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/pr.yml', 'name: PR\non: pull_request\njobs:\n pr:\n runs-on: ubuntu-latest\n name: PR check\n');++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ const pr = (over = {}) => ({++ action: 'opened', number: 7, repository: repoOf(), installation: installationOf(),++ pull_request: { number: 7, head: { sha: SHA, ref: 'feature', repo: repoOf() }, base: { ref: 'main' } },++ ...over,++ });++ assert.equal((await postWebhook(t, 'pull_request', pr(), { delivery: crypto.randomUUID() })).status, 204);++ const plan = await t.db.maybe('SELECT * FROM run_checks_plans');++ assert.equal(plan.event, 'pull_request');++ assert.equal(plan.ref, 'refs/pull/7/merge');++ assert.equal(plan.branch, 'main');++ const run = await t.db.maybe('SELECT * FROM run_jobs');++ assert.equal(run.job.args.event, 'pull_request');++ assert.equal(run.job.args.ref, 'refs/pull/7/merge');++++ const before = await t.db.value('SELECT COUNT(*) FROM run_checks_plans');++ const reads = gh.calls.length;++ assert.equal((await postWebhook(t, 'pull_request', pr({ action: 'closed' }), { delivery: crypto.randomUUID() })).status, 204);++ const fork = pr({ pull_request: { number: 8, head: { sha: SHA, ref: 'feature', repo: repoOf('Fork/demo') }, base: { ref: 'main' } } });++ assert.equal((await postWebhook(t, 'pull_request', fork, { delivery: crypto.randomUUID() })).status, 204);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), before);++ assert.equal(gh.calls.length, reads, 'closed and fork deliveries make no GitHub call');++ } finally { await t.close(); }++ });++++ await check('a failed need skips the later job and its check run', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ const build = await t.db.maybe("SELECT cj.id AS check_job_id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");++ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'exited', exit_code = 1, result = $3::jsonb WHERE id = $1",++ [build.run_job_id, Date.now(), JSON.stringify({ ok: false, conclusion: 'failure', steps: [{ name: 'test', conclusion: 'failure', ms: 5 }] })]);++ await t.checks.tick();++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1, 'the later stage is not queued');++ const test = await t.db.maybe("SELECT * FROM run_checks_jobs WHERE display_name = 'Test'");++ assert.equal(test.state, 'completed');++ assert.equal(test.conclusion, 'skipped');++ const checkRun = gh.checkRuns.get(Number(test.check_run_id));++ assert.equal(checkRun.status, 'completed');++ assert.equal(checkRun.conclusion, 'skipped');++ const plan = await t.db.maybe('SELECT * FROM run_checks_plans');++ assert.equal(plan.state, 'failed');++ } finally { await t.close(); }++ });++++ await check('matrix max-parallel limits the queued combinations', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/matrix.yml', 'name: Matrix\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n max-parallel: 1\n matrix:\n node: [18, 20]\n');++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1);++ const waiting = await t.db.many("SELECT * FROM run_checks_jobs WHERE state = 'waiting'");++ assert.equal(waiting.length, 1);++ const first = await t.db.maybe('SELECT * FROM run_jobs');++ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', finished_ms = $2, wall_ms = 100, result = $3::jsonb WHERE id = $1",++ [first.id, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [] })]);++ await t.checks.tick();++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2);++ const second = await t.db.maybe('SELECT * FROM run_jobs WHERE id <> $1', [first.id]);++ assert.deepEqual(second.job.args.matrix, { node: 20 });++ } finally { await t.close(); }++ });++++ await check('RUN_JOBS=off: no outbound call and no plan', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', CI);++ const t = await bootChecks(gh, { RUN_JOBS: 'off' });++ try {++ await seedApp(t, gh);++ assert.equal(t.checks.start(), false, 'the tick does not start with the background jobs off');++ const res = await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ assert.equal(res.status, 204);++ assert.equal(gh.calls.length, 0);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 0);++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 0);++ } finally { await t.close(); }++ });++++ await check('a lost node is retried once elsewhere, then the check fails', async () => {++ const gh = startGitHubStub();++ gh.setFile('.github/workflows/ci.yml', 'name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n name: Build\n');++ const t = await bootChecks(gh);++ try {++ await seedApp(t, gh);++ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });++ const first = await t.db.maybe('SELECT * FROM run_jobs');++ const job = await t.db.maybe('SELECT * FROM run_checks_jobs');++ assert.equal(job.attempts, 1);++ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'failed' WHERE id = $1", [first.id, Date.now()]);++ await t.checks.tick();++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2);++ const second = await t.db.maybe("SELECT * FROM run_jobs WHERE id <> $1", [first.id]);++ const retried = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [job.id]);++ assert.equal(retried.attempts, 2);++ assert.equal(retried.state, 'queued');++ assert.equal(retried.check_state, 'queued');++ assert.equal(retried.check_run_id, job.check_run_id);++ assert.notEqual(second.id, first.id);++++ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'failed' WHERE id = $1", [second.id, Date.now()]);++ await t.checks.tick();++ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2, 'no third attempt');++ const doneRow = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [job.id]);++ assert.equal(doneRow.state, 'completed');++ assert.equal(doneRow.conclusion, 'failure');++ const checkRun = gh.checkRuns.get(Number(job.check_run_id));++ assert.equal(checkRun.status, 'completed');++ assert.equal(checkRun.conclusion, 'failure');++ } finally { await t.close(); }++ });++++ done();++}++++main().catch((e) => { console.error(e.stack || e); process.exit(1); });@@@ -285,7 +285,7 @@ async function boot(opts = {}) {.filter((e) => (!type || e.event_type === type) && (!jobId || e.payload.job_id === jobId));return {-- app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch,++ app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch, checks: app.locals.checks,// What the stub registry publishes for placement (server/dispatch/placement.js).setOffers: (list) => network.setOffers(list),outboxRows, streamState: (jobId) => stream.ringOf(jobId),
@@ -40,6 +40,24 @@ OV_OAUTH_CLIENT_SECRET=
@@ -40,6 +40,24 @@ OV_OAUTH_CLIENT_SECRET=
# Background jobs: the events relay and the dispatcher's poller. RUN_JOBS=off runs the API alone.
# Background jobs: the events relay and the dispatcher's poller. RUN_JOBS=off runs the API alone.
#RUN_JOBS=on
#RUN_JOBS=on
+# ── GitHub checks on shared nodes (server/checks/) ──────────
+# RUN_SECRETS_KEY seals the checks App's private key, webhook secret and installation tokens at rest:
+# one 32-byte key, base64 (standard or URL-safe) or 64 hex characters. Unset: /admin/checks says so and
+# POST /webhooks/github answers 503. RUN_CHECKS_PROJECT is the project a check job belongs to.
+#RUN_SECRETS_KEY=
+#RUN_CHECKS_PROJECT=
+# The repositories/accounts the scheduler accepts (default OpenVibers) and the org the App is registered in.
+#RUN_CHECKS_OWNERS=OpenVibers
+#RUN_CHECKS_ORG=OpenVibers
+# The network owner allowed on /admin/checks: their subject (usr_…), or OWNER_USERNAME (default goosely).
+#RUN_CHECKS_OWNER=
+#OWNER_USERNAME=goosely
+#RUN_CHECKS=off
+#RUN_CHECKS_TICK_MS=5000
+#RUN_CHECKS_TIMEOUT_MS=8000
+#RUN_CHECKS_GITHUB_API=https://api.github.com
+#RUN_CHECKS_GITHUB_WEB=https://github.com
+
# ── Project caps on a job's limits (run.job-create-request@1); over one is 422 run.limits.exceeded ──
# ── Project caps on a job's limits (run.job-create-request@1); over one is 422 run.limits.exceeded ──
#RUN_LIMITS_MAX_WALL_MS=3600000
#RUN_LIMITS_MAX_WALL_MS=3600000
#RUN_LIMITS_MAX_CPU_MS=3600000
#RUN_LIMITS_MAX_CPU_MS=3600000
@@ -125,6 +125,14 @@ refused like any other token without the grant.
@@ -125,6 +125,14 @@ refused like any other token without the grant.
with the grant is refused. Every request body and query string is validated against its released contract, and so is
with the grant is refused. Every request body and query string is validated against its released contract, and so is
every answer before it is sent; errors are RFC 9457 `application/problem+json`.
every answer before it is sent; errors are RFC 9457 `application/problem+json`.
+## Checks
+
+The checks scheduler (`server/checks/`, [docs/checks.md](docs/checks.md)) turns a matching GitHub workflow job into a
+Run `code` job on a node and reports a GitHub check run. The owner creates the App once at `/admin/checks` (the
+manifest flow); GitHub calls `POST /webhooks/github`; `GET /api/v1/checks/agreement?repo=` gives the owner the node vs
+Actions agreement. Its secrets (the App key, webhook secret, client secret and installation tokens) are sealed with
+`RUN_SECRETS_KEY`; `RUN_CHECKS_PROJECT` is the project a check job belongs to.
+
## Sessions
## Sessions
A session (contracts 0.223.0, the Desks plan's D6) is a browser desk Run places for one person and OpenVibe.Bot leases
A session (contracts 0.223.0, the Desks plan's D6) is a browser desk Run places for one person and OpenVibe.Bot leases
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+# GitHub checks on shared nodes
+
+The checks scheduler (`server/checks/`, research-2026-11-13/shared-node-checks.md piece 2) plans a repository's
+GitHub Actions workflow jobs and runs each one as an ordinary Run job on a node, then reports a GitHub check run.
+It is **shadow first**: `openvibe / …` check runs are not required, and the Actions conclusion of the same
+workflow job is stored beside the node one for the agreement figure.
+
+## Setup (owner)
+
+1. Generate one 32-byte `RUN_SECRETS_KEY`, base64 (standard or URL-safe) or 64 hex characters, and put it in
+ `/etc/openvibe/run.env`. It seals the App's private key, webhook secret, client secret and installation tokens.
+2. Set `RUN_CHECKS_PROJECT` to the project check jobs belong to (`prj_…`). A check job is a normal Run job and needs
+ a project for its `run_job.project_id` and its `run.job.*` events; without it nothing is planned.
+3. Sign in as the network owner and open `/admin/checks`. Press **Create the GitHub App**: the browser posts the
+ manifest to `https://github.com/organizations/OpenVibers/settings/apps/new` and GitHub redirects back to
+ `/admin/checks/created`, where Run exchanges the code and stores the App encrypted.
+4. Press **Install … on OpenVibers** and choose the organization/repositories.
+
+Owner = `RUN_CHECKS_OWNER` (a `usr_…` subject) or `OWNER_USERNAME` (default `goosely`), the same owner name
+Network's owner guard uses.
+
+## Routes
+
+| Route | Who | What |
+|---|---|---|
+| `GET /admin/checks` | owner session | App state (`none`, `created`, installed accounts), the manifest button and the install link |
+| `GET /admin/checks/created` | owner session + state | exchanges the manifest code, seals the App |
+| `POST /webhooks/github` | GitHub signature | HMAC-SHA256 raw body, delivery dedupe for seven days |
+| `GET /api/v1/checks/agreement?repo=` | owner session | paired checks, agreement, average queue/run ms per repo |
+
+Only `push` to the default branch and `pull_request` `opened`/`synchronize`/`reopened` plan checks, and only for
+repositories/installations under `RUN_CHECKS_OWNERS` (default `OpenVibers`). A pull request whose head repository is
+outside the allow-list is ignored (a fork's workflow is never run). `workflow_job.completed` records the Actions
+conclusion; every other delivery is answered `204`.
+
+## What is planned
+
+The workflows under `.github/workflows` at the event's SHA are read with an installation token (cached until five
+minutes before it expires). A workflow is kept when its `on` names the event and its `branches`/`branches-ignore`
+filter matches. Jobs are flattened:
+
+- `needs` become stages: stage 0 is queued at planning, a later stage only when every need succeeded; a failed or
+ skipped need skips the job (and its check run says `skipped`).
+- `strategy.matrix` expands with GitHub's `include`/`exclude` order; `max-parallel` limits the concurrently queued
+ combinations of one matrix.
+- A reusable workflow (`uses: owner/repo/.github/workflows/x.yml@ref`, or local `./…`) is read at its ref and
+ expanded as a sub-play; its jobs carry the caller's needs and their check names are prefixed with the caller.
+- `if:` is not evaluated: an expression the planner cannot read runs the job.
+
+Each planned job becomes one Run job:
+
+```
+class: code, artifact: [email protected],
+args: { kind: 'check', repo: <clone URL>, sha, workflow, job, matrix?, event, ref, timeout_ms },
+requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background',
+ objective: 'balanced', trust: ['first-party', 'user-owned'] },
+egress: public, limits: { wall_ms, cpu_ms, mem_bytes: 4 GiB },
+ttl_ms: min(wall_ms + 10 min, 24 h),
+timeout: the job's timeout-minutes, default 30
+```
+
+A node that goes away mid-job (Run ends failed/expired/cancelled with no result) is retried once on another node;
+the second end settles the check run. A check that produces a conclusion is never retried.
+
+## Check runs and the shadow
+
+One GitHub check run per planned job, named `openvibe / <the job's Actions name>` (matrix values in parentheses),
+is created `queued` at planning, moves to `in_progress` when the Run job starts, and completes with the node's
+conclusion and a summary of its steps. `details_url` is Run's job API URL (`/api/v1/jobs/<id>`): Run has no HTML
+job page today.
+
+Every completion upserts `run_checks_shadow` keyed by repo, SHA and job name, and `workflow_job` writes the Actions
+conclusion into the same row. `GET /api/v1/checks/agreement` aggregates the paired rows.
+
+## Safety
+
+- The App key, webhook secret, client secret and every installation token are AES-256-GCM sealed under
+ `RUN_SECRETS_KEY` (AAD per App field / installation id) before they reach a row. They are never logged, never in
+ a response and never in a node input.
+- A webhook is accepted only with a valid `X-Hub-Signature-256` (constant-time), a fresh `X-GitHub-Delivery`, and
+ a repository and installation account on the allow-list.
+- Steps get no secrets from Run; the node clones the repo with its own credential and `GITHUB_TOKEN` is never
+ passed to the steps. The job's egress is public.
+- Nothing starts at module load. The tick starts from `server/index.js` only with `RUN_JOBS` on; `RUN_JOBS=off`
+ (drills/tests) makes the webhook record a delivery without reading a workflow or calling GitHub.
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+-- phase: expand
+-- The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2): the GitHub App made by the
+-- manifest flow, the webhook deliveries, one plan per workflow run, the planned workflow jobs with their
+-- check runs and Run jobs, and the shadow pair of a node conclusion beside the Actions one.
+--
+-- Secrets (the App private key, its webhook secret, its client secret and every installation token) are
+-- only ever stored sealed by server/checks/crypto.js (AES-256-GCM, RUN_SECRETS_KEY): no column here holds
+-- one in the clear, and no route, log or node input ever carries one.
+CREATE TABLE IF NOT EXISTS run_checks_app (
+ id integer PRIMARY KEY CHECK (id = 1),
+ app_id bigint NOT NULL,
+ slug text NOT NULL,
+ name text NOT NULL,
+ html_url text,
+ pem_enc text NOT NULL,
+ webhook_secret_enc text NOT NULL,
+ client_id text,
+ client_secret_enc text,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+-- The accounts the App is installed on: kept from installation webhooks and refreshed from GET /app/installations.
+CREATE TABLE IF NOT EXISTS run_checks_installations (
+ installation_id bigint PRIMARY KEY,
+ account_login text NOT NULL,
+ account_id bigint,
+ account_type text,
+ suspended_at bigint,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+-- Installation access tokens, cached until five minutes before they expire, sealed at rest.
+CREATE TABLE IF NOT EXISTS run_checks_tokens (
+ installation_id bigint PRIMARY KEY,
+ token_enc text NOT NULL,
+ expires_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+
+-- Webhook deliveries are deduplicated by X-GitHub-Delivery and swept after seven days.
+CREATE TABLE IF NOT EXISTS run_checks_deliveries (
+ delivery_id text PRIMARY KEY,
+ event text,
+ received_at bigint NOT NULL,
+ expires_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS run_checks_deliveries_expiry ON run_checks_deliveries (expires_at);
+
+-- One plan per matching workflow of one event (a push can match several workflow files).
+CREATE TABLE IF NOT EXISTS run_checks_plans (
+ id text PRIMARY KEY CHECK (id ~ '^chkplan_[0-9A-HJKMNP-TV-Z]{26}$'),
+ delivery_id text,
+ repo text NOT NULL,
+ repo_owner text NOT NULL,
+ sha text NOT NULL,
+ event text NOT NULL CHECK (event IN ('push','pull_request')),
+ ref text NOT NULL,
+ branch text,
+ installation_id bigint,
+ workflow_path text NOT NULL,
+ workflow_name text NOT NULL,
+ state text NOT NULL CHECK (state IN ('active','succeeded','failed','skipped')),
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS run_checks_plans_state ON run_checks_plans (state, created_at);
+
+-- One planned workflow job (one matrix combination is one row). `check_state` is the GitHub check run's
+-- status; `state` is the scheduler's: waiting (needs pending), queuing (a tick claimed it), queued,
+-- running, completed.
+CREATE TABLE IF NOT EXISTS run_checks_jobs (
+ id text PRIMARY KEY CHECK (id ~ '^chkjob_[0-9A-HJKMNP-TV-Z]{26}$'),
+ plan_id text NOT NULL REFERENCES run_checks_plans(id) ON DELETE CASCADE,
+ job_key text NOT NULL,
+ job_name text NOT NULL,
+ display_name text NOT NULL,
+ workflow_path text NOT NULL,
+ workflow_job_key text NOT NULL,
+ stage integer NOT NULL,
+ needs jsonb NOT NULL DEFAULT '[]'::jsonb,
+ matrix jsonb,
+ matrix_group text,
+ max_parallel integer,
+ timeout_minutes integer NOT NULL,
+ check_run_id bigint,
+ check_state text NOT NULL DEFAULT 'queued' CHECK (check_state IN ('queued','in_progress','completed')),
+ state text NOT NULL DEFAULT 'waiting' CHECK (state IN ('waiting','queuing','queued','running','completed')),
+ conclusion text,
+ run_job_id text,
+ attempts integer NOT NULL DEFAULT 0,
+ report_errors integer NOT NULL DEFAULT 0,
+ queued_at bigint,
+ started_at bigint,
+ finished_at bigint,
+ queue_ms bigint,
+ run_ms bigint,
+ node_id text,
+ summary text,
+ error text,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (plan_id, job_key)
+);
+CREATE INDEX IF NOT EXISTS run_checks_jobs_plan_state ON run_checks_jobs (plan_id, state);
+CREATE INDEX IF NOT EXISTS run_checks_jobs_run_job ON run_checks_jobs (run_job_id);
+
+-- Shadow first: the same workflow job on a node and on Actions, one row, whichever arrives first.
+CREATE TABLE IF NOT EXISTS run_checks_shadow (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ repo text NOT NULL,
+ sha text NOT NULL,
+ workflow_name text NOT NULL DEFAULT '',
+ job_name text NOT NULL,
+ check_run_id bigint,
+ node_conclusion text,
+ actions_conclusion text,
+ queue_ms bigint,
+ run_ms bigint,
+ node_started_at bigint,
+ node_finished_at bigint,
+ actions_started_at bigint,
+ actions_finished_at bigint,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS run_checks_shadow_lookup ON run_checks_shadow (repo, sha, job_name);
+CREATE INDEX IF NOT EXISTS run_checks_shadow_repo ON run_checks_shadow (repo, created_at);
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "yaml": "^2.9.1"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1802,6 +1803,21 @@
@@ -1802,6 +1803,21 @@
"engines": {
"engines": {
"node": ">=0.4"
"node": ">=0.4"
}
}
+ },
+ "node_modules/yaml": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
}
}
}
@@ -29,7 +29,8 @@
@@ -29,7 +29,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.232.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "yaml": "^2.9.1"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -35,6 +35,7 @@ const { createDeskClient } = require('./sessions/desks');
@@ -35,6 +35,7 @@ const { createDeskClient } = require('./sessions/desks');
const { createSessions } = require('./sessions/service');
const { createSessions } = require('./sessions/service');
const { inputError } = require('./util');
const { inputError } = require('./util');
const { createRunReadiness, registerRunGauges } = require('./observability');
const { createRunReadiness, registerRunGauges } = require('./observability');
+const { createChecks } = require('./checks');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
// A create request may carry 512 KiB of args (run.job-create-request@1) on top of its inputs and limits,
// A create request may carry 512 KiB of args (run.job-create-request@1) on top of its inputs and limits,
@@ -62,6 +63,10 @@ function createApp(opts = {}) {
@@ -62,6 +63,10 @@ function createApp(opts = {}) {
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
const fillNotifier = opts.fillNotifier || createFillNotifier(config, { fetchImpl, now });
const fillNotifier = opts.fillNotifier || createFillNotifier(config, { fetchImpl, now });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
+ // The checks scheduler (server/checks/): the GitHub App's owner page, the webhook and the agreement
+ // query. It owns no module-level state and starts nothing here; server/index.js starts its tick with
+ // the other background jobs. Without RUN_SECRETS_KEY the page says so and the webhook answers 503.
+ const checks = opts.checks || createChecks({ config, db, jobsStore: require('./jobs/store'), events, now, fetchImpl, log, userAuth, sso: opts.checksSso || null });
// Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.
// Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.
const desks = opts.desks || createDeskClient({ config, fetchImpl, now });
const desks = opts.desks || createDeskClient({ config, fetchImpl, now });
const broker = opts.broker || require('./compute/broker').createBroker({ config, db, desks, now, fetchImpl, log });
const broker = opts.broker || require('./compute/broker').createBroker({ config, db, desks, now, fetchImpl, log });
@@ -118,6 +123,11 @@ function createApp(opts = {}) {
@@ -118,6 +123,11 @@ function createApp(opts = {}) {
app.use(http.middleware());
app.use(http.middleware());
app.use(cookieParser());
app.use(cookieParser());
app.use('/auth', require('./web-sso').webSso({ config, fetchImpl, log }));
app.use('/auth', require('./web-sso').webSso({ config, fetchImpl, log }));
+ // The checks App's owner page and its webhook. The webhook reads the raw bytes GitHub signed, so it
+ // mounts its own body parser here, before /api/v1's JSON parser and the catch-all.
+ app.use('/admin/checks', checks.admin);
+ app.post('/webhooks/github', express.raw({ type: '*/*', limit: '10mb' }), (req, res, next) => Promise.resolve(checks.webhook(req, res)).catch(next));
+ app.get('/api/v1/checks/agreement', (req, res, next) => Promise.resolve(checks.agreement(req, res)).catch(next));
// Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are
// Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are
// /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.
// /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.
@@ -230,7 +240,7 @@ function createApp(opts = {}) {
@@ -230,7 +240,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions, broker });
+ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions, broker, checks });
return app;
return app;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The checks scheduler's secrets at rest (research-2026-11-13/shared-node-checks.md, safety): the GitHub
+ * App's private key, its webhook secret, its client secret and every installation access token are sealed
+ * with AES-256-GCM under RUN_SECRETS_KEY before they reach a row. Nothing in this module logs a value.
+ *
+ * RUN_SECRETS_KEY is one 32-byte key, base64 (standard or URL-safe) or 64 hex characters. A missing or
+ * malformed key is not fatal at boot: the App page says encryption is not installed and the webhook, which
+ * cannot verify a signature without its secret, answers 503. The envelope is
+ *
+ * ovc1.<iv base64url>.<tag base64url>.<ciphertext base64url>
+ *
+ * with the additional data `run.checks|<purpose>`, so a ciphertext sealed for one App field cannot be
+ * opened as another (or as an installation token).
+ */
+const crypto = require('crypto');
+
+const PREFIX = 'ovc1';
+const KEY_BYTES = 32;
+const IV_BYTES = 12;
+const TAG_BYTES = 16;
+
+/** The 32-byte key from RUN_SECRETS_KEY, or null when unset/not exactly 32 bytes. Never throws with the value. */
+function keyOf(raw) {
+ if (raw == null || raw === '') return null;
+ const text = String(raw).trim();
+ if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, 'hex');
+ if (/^[A-Za-z0-9+/_-]{43}=?$/.test(text)) {
+ const bytes = Buffer.from(text.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+ return bytes.length === KEY_BYTES ? bytes : null;
+ }
+ return null;
+}
+
+const aadOf = (purpose) => Buffer.from(`run.checks|${String(purpose)}`, 'utf8');
+
+/** Seal one string. → the envelope text. Throws when the key is missing or malformed. */
+function seal(key, value, purpose) {
+ if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new TypeError('checks.crypto: a 32-byte key is required');
+ const iv = crypto.randomBytes(IV_BYTES);
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+ cipher.setAAD(aadOf(purpose));
+ const body = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
+ return [PREFIX, iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), body.toString('base64url')].join('.');
+}
+
+/** Open one envelope. Throws a stable error (never the plaintext) when it is foreign, truncated or tampered with. */
+function open(key, envelope, purpose) {
+ if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new Error('checks.crypto: a 32-byte key is required');
+ const parts = String(envelope || '').split('.');
+ if (parts.length !== 4 || parts[0] !== PREFIX) throw new Error('checks.crypto: not a sealed checks value');
+ const iv = Buffer.from(parts[1], 'base64url');
+ const tag = Buffer.from(parts[2], 'base64url');
+ const body = Buffer.from(parts[3], 'base64url');
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error('checks.crypto: malformed envelope');
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+ decipher.setAAD(aadOf(purpose));
+ decipher.setAuthTag(tag);
+ return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
+}
+
+module.exports = { keyOf, seal, open, KEY_BYTES, PREFIX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * The small GitHub client the checks scheduler needs (no SDK): an App JWT, installation access tokens, the
+ * contents of the workflow files at a SHA, check runs, the App's installations and the manifest conversion.
+ *
+ * Every call takes an explicit token. This module never logs a request header, a token or the PEM; an error
+ * says an HTTP status and GitHub's own message, never the credential it was sent with. The base URL is a
+ * config value so a test can point it at a stub and the production default stays https://api.github.com.
+ */
+const crypto = require('crypto');
+
+class GitHubError extends Error {
+ constructor(code, detail, { status = null } = {}) {
+ super(detail || code);
+ this.name = 'GitHubError';
+ this.code = code;
+ this.detail = detail;
+ this.status = status;
+ }
+}
+
+const b64url = (value) => Buffer.from(value).toString('base64url');
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+
+/** A GitHub App JWT (RS256, iss = the App id, under ten minutes), from the App's private key. */
+function appJwt(app, nowMs = Date.now()) {
+ const iat = Math.floor(nowMs / 1000) - 60;
+ const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
+ const payload = b64url(JSON.stringify({ iat, exp: iat + 540, iss: String(app.app_id) }));
+ const signature = crypto.sign('RSA-SHA256', Buffer.from(`${header}.${payload}`, 'utf8'), app.pem).toString('base64url');
+ return `${header}.${payload}.${signature}`;
+}
+
+function createGitHub({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ const checks = (config && config.checks) || {};
+ const api = trim(checks.githubApi || 'https://api.github.com');
+ const timeoutMs = Math.max(1000, Number(checks.requestTimeoutMs) || 8000);
+ const agent = 'OpenVibe.Run-checks (+https://openvibe.run)';
+
+ /**
+ * One request. `token` is a JWT or an installation token, `raw` asks for the contents API's raw body
+ * (text). → { status, text, json } on success; throws GitHubError otherwise. Never echoes the token.
+ */
+ async function request(path, { method = 'GET', token = null, body = undefined, raw = false } = {}) {
+ const headers = { Accept: raw ? 'application/vnd.github.raw+json' : 'application/vnd.github+json', 'User-Agent': agent, 'X-GitHub-Api-Version': '2022-11-28' };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetchImpl(`${api}${path}`, {
+ method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (e) {
+ const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? `no answer within ${timeoutMs} ms` : 'unreachable';
+ throw new GitHubError('checks.github_unavailable', `GitHub did not answer (${why})`);
+ }
+ const text = await res.text().catch(() => '');
+ if (!res.ok) {
+ let message = '';
+ try { message = String((JSON.parse(text) || {}).message || '').slice(0, 300); } catch { /* not json */ }
+ throw new GitHubError('checks.github_refused', `GitHub answered HTTP ${res.status}${message ? `: ${message}` : ''}`, { status: res.status });
+ }
+ let json = null;
+ if (!raw && text) { try { json = JSON.parse(text); } catch { /* an empty or non-JSON answer is null */ } }
+ return { status: res.status, text, json, headers: res.headers };
+ }
+
+ /** A short-lived installation token (one hour). The caller caches and seals it; this module never does. */
+ async function createInstallationToken(app, installationId) {
+ const out = await request(`/app/installations/${encodeURIComponent(String(installationId))}/access_tokens`, {
+ method: 'POST', token: appJwt(app, now()),
+ });
+ const token = out.json && out.json.token;
+ const expiresAt = Date.parse(out.json && out.json.expires_at);
+ if (typeof token !== 'string' || !token || !Number.isFinite(expiresAt)) throw new GitHubError('checks.github_refused', 'GitHub issued no installation token');
+ return { token, expiresAt };
+ }
+
+ /** One file at a ref through the contents API (raw), or null when GitHub says 404. */
+ async function readFile(app, token, { owner, repo, path, ref }) {
+ const encoded = String(path).split('/').map(encodeURIComponent).join('/');
+ try {
+ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encoded}?ref=${encodeURIComponent(ref)}`, { token, raw: true });
+ return out.text;
+ } catch (e) {
+ if (e.status === 404) return null;
+ throw e;
+ }
+ }
+
+ /** The workflow files under .github/workflows at a SHA → [{ path, source }]. A repo with none is an empty list. */
+ async function listWorkflows(app, token, { owner, repo, sha }) {
+ let listing;
+ try {
+ listing = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/.github/workflows?ref=${encodeURIComponent(sha)}`, { token });
+ } catch (e) {
+ if (e.status === 404) return [];
+ throw e;
+ }
+ const files = Array.isArray(listing.json) ? listing.json : [];
+ const out = [];
+ for (const file of files) {
+ if (!file || file.type !== 'file' || !/\.ya?ml$/i.test(String(file.name || ''))) continue;
+ const source = await readFile(app, token, { owner, repo, path: file.path, ref: sha });
+ if (source != null) out.push({ path: file.path, source });
+ }
+ return out;
+ }
+
+ /** Create a check run (queued) → { id, html_url }. */
+ async function createCheckRun(token, { owner, repo, name, headSha, detailsUrl = null, externalId = null }) {
+ const body = { name, head_sha: headSha, status: 'queued', ...(detailsUrl ? { details_url: detailsUrl } : {}), ...(externalId ? { external_id: externalId } : {}) };
+ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs`, { method: 'POST', token, body });
+ if (!out.json || !out.json.id) throw new GitHubError('checks.github_refused', 'GitHub created no check run');
+ return { id: out.json.id, html_url: out.json.html_url || null };
+ }
+
+ /** Update a check run (status, conclusion, summary, details link). */
+ async function updateCheckRun(token, { owner, repo, checkRunId, status, conclusion = null, output = null, detailsUrl = null, startedAt = null, completedAt = null }) {
+ const body = {
+ status,
+ ...(conclusion ? { conclusion } : {}),
+ ...(output ? { output } : {}),
+ ...(detailsUrl ? { details_url: detailsUrl } : {}),
+ ...(startedAt ? { started_at: startedAt } : {}),
+ ...(completedAt ? { completed_at: completedAt } : {}),
+ };
+ const out = await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/check-runs/${encodeURIComponent(String(checkRunId))}`, { method: 'PATCH', token, body });
+ return out.json;
+ }
+
+ /** Exchange a manifest flow code for the App's id, slug, PEM, webhook secret and client credentials. */
+ async function convertManifest(code) {
+ const out = await request(`/app-manifests/${encodeURIComponent(code)}/conversions`, { method: 'POST' });
+ const app = out.json;
+ if (!app || !app.id || !app.pem || !app.webhook_secret) throw new GitHubError('checks.github_refused', 'GitHub returned an incomplete App registration');
+ return app;
+ }
+
+ /** Every installation of the App (GET /app/installations), for the owner page. */
+ async function listInstallations(app) {
+ const out = await request('/app/installations?per_page=100', { token: appJwt(app, now()) });
+ return Array.isArray(out.json) ? out.json : [];
+ }
+
+ return { request, createInstallationToken, readFile, listWorkflows, createCheckRun, updateCheckRun, convertManifest, listInstallations, api };
+}
+
+module.exports = { createGitHub, appJwt, GitHubError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+/**
+ * The checks scheduler surface: createChecks (server/checks/service.js) plus the two pieces a reviewer or a
+ * test may want directly — the planner and the secret envelope. Nothing here starts at module load.
+ */
+const { createChecks, manifestFor, safeEqual } = require('./service');
+const planner = require('./planner');
+const crypto = require('./crypto');
+
+module.exports = { createChecks, manifestFor, safeEqual, planner, checksCrypto: crypto };
new file mode 100644
new file mode 100644
@@ -0,0 +1,289 @@
@@ -0,0 +1,289 @@
+'use strict';
+/**
+ * The checks planner (research-2026-11-13/shared-node-checks.md, piece 2): act's reading of a workflow, not
+ * an evaluator of it. A workflow at the SHA is kept when its `on` names the event and its branch filters
+ * match; its jobs are expanded into one flat play per workflow job, matrix combination included, with
+ *
+ * - `needs` turned into stages (a later stage is queued only when every need succeeded),
+ * - `strategy.matrix` with include/exclude and max-parallel,
+ * - a reusable workflow (`uses: owner/repo/.github/workflows/x.yml@ref`) read at its ref and expanded as
+ * a sub-play (the caller job is not a Run job; its sub-jobs carry its needs).
+ *
+ * `if:` is never evaluated: an expression this planner cannot read runs the job, which is act's default.
+ * The reading of files (GitHub contents, installation token) is a callback so this module is pure and a
+ * fixture test needs no network.
+ */
+const YAML = require('yaml');
+
+const DEFAULT_TIMEOUT_MINUTES = 30;
+const MAX_REUSE_DEPTH = 4;
+const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
+const listOf = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]);
+const baseName = (p) => String(p || '').split('/').pop().replace(/\.ya?ml$/i, '');
+
+/** The `on` map of a workflow: a string, a list of strings, or the event → filters map. */
+function onMap(on) {
+ if (typeof on === 'string') return { [on]: null };
+ if (Array.isArray(on)) return Object.fromEntries(on.filter((e) => typeof e === 'string').map((e) => [e, null]));
+ const m = obj(on);
+ return m ? { ...m } : {};
+}
+
+/** A GitHub branch glob (`*` one segment, `**` any) as a regular expression. */
+function globOf(pattern) {
+ let re = '';
+ const text = String(pattern || '');
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (c === '*') {
+ if (text[i + 1] === '*') { re += '.*'; i++; } else re += '[^/]*';
+ } else if (c === '?') re += '[^/]';
+ else re += c.replace(/[\\^$+.()|[\]{}]/g, '\\$&');
+ }
+ try { return new RegExp(`^${re}$`); } catch { return null; }
+}
+
+/** Does one branch match a GitHub `branches` list (positive globs and `!` negations)? */
+function branchMatches(branch, patterns) {
+ const list = listOf(patterns).filter((p) => typeof p === 'string');
+ if (!list.length) return true;
+ const negative = list.filter((p) => p.startsWith('!')).map((p) => globOf(p.slice(1))).filter(Boolean);
+ const positive = list.filter((p) => !p.startsWith('!')).map(globOf).filter(Boolean);
+ if (negative.some((re) => re.test(branch))) return false;
+ if (positive.length && !positive.some((re) => re.test(branch))) return false;
+ return true;
+}
+
+/**
+ * Does a workflow's `on` match this event? `branch` is the branch the event acts on: the pushed branch,
+ * or a pull request's base branch. `paths` filters are not evaluated (the job runs), as act does.
+ */
+function eventMatches(on, { event, branch = null, action = null } = {}) {
+ const map = onMap(on);
+ if (!Object.prototype.hasOwnProperty.call(map, event)) return false;
+ const cfg = map[event];
+ if (cfg == null) return true;
+ const filters = obj(cfg);
+ if (!filters) return true;
+ const types = listOf(filters.types);
+ if (types.length && action && !types.includes(action)) return false;
+ if (filters['branches-ignore'] && branch && listOf(filters['branches-ignore']).some((p) => (globOf(p) || /$^/).test(branch))) return false;
+ if (filters.branches && branch && !branchMatches(branch, filters.branches)) return false;
+ // A push filter naming only tags does not run for a branch push.
+ if (event === 'push' && filters.tags && !filters.branches && branch) return false;
+ return true;
+}
+
+// ── strategy.matrix ────────────────────────────────────────────────────────────
+
+/**
+ * Every combination of `strategy.matrix`, or [null] when there is none. include entries are merged into
+ * every combination whose original matrix keys they do not overwrite (a same value is not an overwrite);
+ * an include that fits none becomes a combination of its own. exclude removes combinations matching all
+ * of its pairs. This is GitHub's documented order.
+ */
+function matrixCombinations(strategy) {
+ const s = obj(strategy);
+ const matrix = s && obj(s.matrix);
+ if (!matrix) return [null];
+ const keys = Object.keys(matrix).filter((k) => k !== 'include' && k !== 'exclude');
+ let combos = [{}];
+ for (const key of keys) {
+ const values = listOf(matrix[key]);
+ const next = [];
+ for (const combo of combos) for (const value of values) next.push({ ...combo, [key]: value });
+ combos = next;
+ }
+ for (const include of listOf(matrix.include)) {
+ const inc = obj(include);
+ if (!inc) continue;
+ let merged = false;
+ for (let i = 0; i < combos.length; i++) {
+ const combo = combos[i];
+ const overwrites = Object.keys(inc).some((k) => keys.includes(k) && Object.prototype.hasOwnProperty.call(combo, k) && combo[k] !== inc[k]);
+ if (overwrites) continue;
+ combos[i] = { ...combo, ...inc };
+ merged = true;
+ }
+ if (!merged) combos.push({ ...inc });
+ }
+ for (const exclude of listOf(matrix.exclude)) {
+ const ex = obj(exclude);
+ if (!ex) continue;
+ const pairs = Object.entries(ex);
+ combos = combos.filter((combo) => !pairs.every(([k, v]) => combo[k] === v));
+ }
+ return combos;
+}
+
+/** The `(18, ubuntu-latest)` part of a matrix job's Actions name; empty for no matrix. */
+function matrixLabel(combo) {
+ if (!combo) return '';
+ const values = Object.values(combo);
+ if (!values.length) return '';
+ return ` (${values.map((v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v))).join(', ')})`;
+}
+
+const maxParallelOf = (strategy) => {
+ const s = obj(strategy);
+ const n = s ? Number(s['max-parallel']) : NaN;
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
+};
+
+const timeoutOf = (job) => {
+ const n = Number(job && job['timeout-minutes']);
+ return Number.isFinite(n) && n > 0 ? Math.min(24 * 60, Math.floor(n)) : DEFAULT_TIMEOUT_MINUTES;
+};
+
+// ── jobs, needs and reusable workflows ─────────────────────────────────────────
+
+/** Parse a job's `uses`: a local path (read at the plan's SHA) or owner/repo/path@ref. */
+function parseUses(uses) {
+ const text = String(uses || '').trim();
+ if (!text) return null;
+ if (text.startsWith('./') || text.startsWith('.github/')) return { local: true, path: text.replace(/^\.\//, ''), ref: null };
+ const m = /^([^/\s]+)\/([^/\s]+)\/(.+)@([^\s]+)$/.exec(text);
+ if (!m) return null;
+ return { local: false, owner: m[1], repo: m[2], path: m[3], ref: m[4] };
+}
+
+/**
+ * Expand a workflow's jobs, recursively through reusable workflows. Returns entries with abstract needs
+ * (keys in this play) and `provides`: a workflow job key → the entries that satisfy a `needs` on it (a
+ * matrix job's variants, or every job of a reusable workflow).
+ */
+async function expandWorkflow({ doc, path, keyPrefix = '', displayPrefix = '', inheritedNeeds = [], callerMatrix = null,
+ readWorkflow, depth = 0, errors = [] }) {
+ const entries = [];
+ const provides = new Map();
+ const jobs = obj(doc && doc.jobs) || {};
+ for (const [jobKey, jobValue] of Object.entries(jobs)) {
+ const job = obj(jobValue) || {};
+ const fullKey = `${keyPrefix}${jobKey}`;
+ const jobName = typeof job.name === 'string' && job.name ? job.name : jobKey;
+ const needsHere = listOf(job.needs).filter((n) => typeof n === 'string').map((n) => `${keyPrefix}${n}`);
+
+ if (typeof job.uses === 'string' && job.uses) {
+ const uses = parseUses(job.uses);
+ if (!uses) { errors.push({ path, job: jobKey, message: `cannot read uses: ${job.uses}` }); continue; }
+ if (depth >= MAX_REUSE_DEPTH) { errors.push({ path, job: jobKey, message: 'reusable workflows nest too deeply' }); continue; }
+ let source = null;
+ try { source = readWorkflow ? await readWorkflow(uses, path) : null; } catch (e) {
+ errors.push({ path, job: jobKey, message: `reusable workflow ${job.uses} could not be read (${e.code || e.message})` });
+ }
+ let called = null;
+ if (source) {
+ try { called = YAML.parse(source); } catch (e) { errors.push({ path, job: jobKey, message: `reusable workflow ${job.uses} is not YAML` }); }
+ }
+ if (!called || !obj(called.jobs)) { provides.set(fullKey, []); continue; }
+ const callerCombos = matrixCombinations(job.strategy);
+ for (let ci = 0; ci < callerCombos.length; ci++) {
+ const combo = callerCombos[ci];
+ const callKey = callerCombos.length > 1 ? `${fullKey}#${ci}` : fullKey;
+ const childPrefix = `${callKey}/`;
+ const childDisplay = `${displayPrefix}${jobName}${matrixLabel(combo)} / `;
+ const childInherited = needsHere.length ? needsHere : inheritedNeeds;
+ const childMatrix = combo ? { ...(callerMatrix || {}), ...combo } : callerMatrix;
+ const child = await expandWorkflow({
+ doc: called, path: uses.path, keyPrefix: childPrefix, displayPrefix: childDisplay,
+ inheritedNeeds: childInherited, callerMatrix: childMatrix, readWorkflow, depth: depth + 1, errors,
+ });
+ entries.push(...child.entries);
+ // child.provides' keys already carry childPrefix: merge them as they are.
+ for (const [k, v] of child.provides) provides.set(k, v);
+ if (!provides.has(fullKey)) provides.set(fullKey, []);
+ provides.get(fullKey).push(...child.entries.map((e) => e.key));
+ }
+ if (!provides.has(fullKey)) provides.set(fullKey, []);
+ continue;
+ }
+
+ const combos = matrixCombinations(job.strategy);
+ const maxParallel = maxParallelOf(job.strategy);
+ const timeout = timeoutOf(job);
+ const variants = [];
+ for (let ci = 0; ci < combos.length; ci++) {
+ const combo = combos[ci] ? { ...(callerMatrix || {}), ...combos[ci] } : callerMatrix;
+ const key = combos.length > 1 ? `${fullKey}#${ci}` : fullKey;
+ const displayName = `${displayPrefix}${jobName}${matrixLabel(combo)}`;
+ entries.push({
+ key, group: fullKey, job_key: jobKey, job_name: jobName, display_name: displayName,
+ workflow_path: path, workflow_job_key: jobKey, needs: needsHere.length ? needsHere : inheritedNeeds, matrix: combo,
+ max_parallel: combos.length > 1 ? maxParallel : null, timeout_minutes: timeout,
+ name_prefix: displayPrefix,
+ });
+ variants.push(key);
+ }
+ provides.set(fullKey, variants);
+ }
+ return { entries, provides };
+}
+
+/** Resolve abstract needs to entry keys, then compute each entry's stage. Cycles and unknown needs are errors. */
+function resolveStages(entries, provides, errors = []) {
+ const byKey = new Map(entries.map((e) => [e.key, e]));
+ const stageOf = new Map();
+ const visiting = new Set();
+ function stage(key) {
+ if (stageOf.has(key)) return stageOf.get(key);
+ const entry = byKey.get(key);
+ if (!entry) return 0;
+ if (visiting.has(key)) throw new Error(`a needs cycle passes through ${key}`);
+ visiting.add(key);
+ let n = 0;
+ for (const need of entry.needs) n = Math.max(n, stage(need) + 1);
+ visiting.delete(key);
+ stageOf.set(key, n);
+ return n;
+ }
+ const kept = [];
+ for (const entry of entries) {
+ const resolved = [];
+ for (const need of entry.needs) {
+ const supplied = provides.get(need);
+ if (supplied) resolved.push(...supplied);
+ else if (byKey.has(need)) resolved.push(need);
+ else errors.push({ path: entry.workflow_path, job: entry.job_key, message: `needs ${need}, which the workflow does not define` });
+ }
+ entry.needs = [...new Set(resolved.filter((k) => k !== entry.key))];
+ kept.push(entry);
+ }
+ for (const entry of kept) {
+ try { entry.stage = stage(entry.key); } catch (e) {
+ errors.push({ path: entry.workflow_path, job: entry.job_key, message: e.message });
+ entry.error = true;
+ }
+ }
+ return kept.filter((e) => !e.error).sort((a, b) => a.stage - b.stage || a.key.localeCompare(b.key));
+}
+
+/**
+ * planWorkflows({ workflows: [{path, source}], event, branch, action, readWorkflow, log }) →
+ * { workflows: [{ path, name, jobs }], errors }. Every job is flat: one matrix combination is one job;
+ * `needs` is the list of flat keys it waits for; `stage` is its level.
+ */
+async function planWorkflows({ workflows = [], event, branch = null, action = null, readWorkflow = null, log = console } = {}) {
+ const out = { workflows: [], errors: [] };
+ for (const file of workflows) {
+ let doc;
+ try { doc = YAML.parse(file.source); } catch (e) {
+ out.errors.push({ path: file.path, message: `not YAML: ${e.message}` });
+ continue;
+ }
+ if (!obj(doc) || !obj(doc.jobs)) continue;
+ if (!eventMatches(doc.on, { event, branch, action })) continue;
+ const name = typeof doc.name === 'string' && doc.name ? doc.name : baseName(file.path);
+ const expanded = await expandWorkflow({ doc, path: file.path, readWorkflow, errors: out.errors });
+ const jobs = resolveStages(expanded.entries, expanded.provides, out.errors);
+ if (jobs.length) out.workflows.push({ path: file.path, name, jobs });
+ if (out.errors.length && log && log.warn) {
+ for (const e of out.errors.slice(-3)) log.warn(`[Run] checks planner: ${e.path}${e.job ? ` ${e.job}` : ''}: ${e.message}`);
+ }
+ }
+ return out;
+}
+
+module.exports = {
+ planWorkflows, eventMatches, matrixCombinations, matrixLabel, parseUses, onMap, branchMatches, globOf,
+ DEFAULT_TIMEOUT_MINUTES,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,726 @@
@@ -0,0 +1,726 @@
+'use strict';
+/**
+ * The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2).
+ *
+ * /admin/checks the network owner's page: the App's state and the manifest-flow button
+ * /admin/checks/created the manifest redirect: state checked, code exchanged, secrets sealed
+ * POST /webhooks/github raw body, HMAC-SHA256 of the stored webhook secret (constant time),
+ * X-GitHub-Delivery dedupe, only the allow-list's repositories, only
+ * pull_request opened/synchronize/reopened and push to the default branch
+ * GET /api/v1/checks/agreement node vs Actions agreement, queue ms and run ms per repo (owner only)
+ *
+ * A matching event reads the workflows at the SHA with an installation token, plans act's jobs, creates one
+ * queued GitHub check run per planned job and queues the first stage as ordinary Run jobs (class code,
+ * `[email protected]`, args `{ kind: 'check', … }`). A tick follows the Run jobs, reports in_progress and
+ * completed to GitHub, retries a node that went away once, skips the jobs that need a job that did not
+ * succeed, and writes the shadow pair of the node's conclusion beside the Actions one.
+ *
+ * Nothing runs at module load. server/index.js starts the tick only with RUN_JOBS on; the webhook and the
+ * owner page are request-driven. Every outbound call needs the App: before it exists the webhook answers
+ * 503 and the page shows how to create it.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { ids } = require('openvibe-contracts');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const store = require('./store');
+const { createGitHub } = require('./github');
+const { planWorkflows } = require('./planner');
+const { keyOf, seal, open } = require('./crypto');
+const views = require('./views');
+
+const TOKEN_MARGIN_MS = 5 * 60_000;
+const DELIVERY_TTL_MS = 7 * 24 * 3600_000;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SHA_RE = /^[0-9a-f]{40}$/i;
+const CHECK_CONCLUSIONS = new Set(['success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required', 'stale']);
+const MAP_CONCLUSION = { success: 'success', failure: 'failure', cancelled: 'cancelled', timed_out: 'timed_out', timedout: 'timed_out', skipped: 'skipped' };
+const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
+const parseJson = (v) => {
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
+ try { return obj(JSON.parse(v)); } catch { return {}; }
+};
+const safeEqual = (a, b) => {
+ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+};
+const newPlanId = () => `chkplan_${ids.ulid()}`;
+const newJobId = () => `chkjob_${ids.ulid()}`;
+
+/** The manifest posted to GitHub. Public configuration only; the secrets come back from the conversion call. */
+function manifestFor({ baseUrl } = {}) {
+ return {
+ name: 'OpenVibe Checks',
+ url: baseUrl,
+ hook_attributes: { url: `${baseUrl}/webhooks/github`, active: true },
+ redirect_url: `${baseUrl}/admin/checks/created`,
+ public: false,
+ default_permissions: {
+ checks: 'write', statuses: 'write', contents: 'read', pull_requests: 'read', metadata: 'read', actions: 'read',
+ },
+ default_events: ['push', 'pull_request', 'check_suite', 'workflow_job'],
+ };
+}
+
+/**
+ * createChecks({ config, db, jobsStore, events, now, fetchImpl, log, sso }) → the checks service.
+ * `jobsStore` is server/jobs/store.js (mint, frameOf, …); `events` is server/jobs/events.js.
+ */
+function createChecks({ config, db, jobsStore = null, events = null, now = () => Date.now(), fetchImpl = globalThis.fetch, log = console, userAuth = null, sso = null } = {}) {
+ const cfg = (config && config.checks) || {};
+ const key = keyOf(cfg.secretsKey);
+ const owners = (Array.isArray(cfg.owners) && cfg.owners.length ? cfg.owners : ['OpenVibers'])
+ .map((o) => String(o).toLowerCase()).filter(Boolean);
+ const org = cfg.org || 'OpenVibers';
+ const project = PROJECT_RE.test(String(cfg.project || '')) ? String(cfg.project) : null;
+ const githubWeb = String(cfg.githubWeb || 'https://github.com').replace(/\/+$/, '');
+ const tickMs = Math.max(500, Number(cfg.tickMs) || 5000);
+ const enabled = cfg.enabled !== false && Boolean(key);
+ const github = createGitHub({ config, fetchImpl, now, log });
+ const tokenCache = new Map(); // installation id → { token, expiresAt }
+ let timer = null;
+ let loggedNoProject = false;
+
+ let ssoClient = sso;
+ if (!ssoClient && config && config.baseUrl && config.oauth && config.oauth.clientId) {
+ try {
+ ssoClient = createSsoClient({
+ site: 'run', siteName: 'OpenVibe.Run', baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ redirectUri: `${config.baseUrl}/auth/callback`, scope: 'profile theme',
+ networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
+ issuer: config.network.issuer, publicKey: config.network.publicKey, audience: ['openvibe.network'],
+ secureCookies: config.isProduction, httpOnlyAccess: true, fetch: fetchImpl, log,
+ });
+ } catch (e) { log.warn(`[Run] checks: sign-in for /admin/checks is unavailable (${e.message})`); }
+ }
+ const optionalAuth = ssoClient ? ssoClient.optionalAuth() : (_req, _res, next) => next();
+
+ /** A Bearer Network user token as a session (the API's own verify), for a script or the owner's API client. */
+ function bearerAuth(req, res, next) {
+ if (req.user || !userAuth) return next();
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return next();
+ userAuth.verify(header.slice(7).trim()).then((claims) => { if (claims) req.user = claims; next(); }, () => next());
+ }
+
+ /** The configured owner: their Network subject, or the Network owner's username (the same rule Network uses). */
+ function isOwner(user) {
+ if (!user) return false;
+ const subject = String(user.subject_id || '');
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return false;
+ if (cfg.owner && subject === cfg.owner) return true;
+ const username = String(user.username || '').toLowerCase();
+ return Boolean(username) && username === String(cfg.ownerUsername || 'goosely').toLowerCase();
+ }
+
+ function ownerPage(req, res, next) {
+ optionalAuth(req, res, () => bearerAuth(req, res, () => {
+ if (!req.user) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl || '/admin/checks')}`);
+ if (!isOwner(req.user)) return res.status(403).type('html').send(views.errorPage({ title: 'Owner access required', text: 'Only the network owner can manage the checks App.', back: '/' }));
+ return next();
+ }));
+ }
+
+ function ownerApi(req, res, next) {
+ optionalAuth(req, res, () => bearerAuth(req, res, () => {
+ if (!req.user) return res.status(401).json({ error: 'owner session required' });
+ if (!isOwner(req.user)) return res.status(403).json({ error: 'owner access required' });
+ return next();
+ }));
+ }
+
+ /** The App row with its sealed fields opened, or null. Never exposes a secret; `unreadable` says a key is wrong. */
+ async function appRow() {
+ const row = await store.getApp(db);
+ if (!row) return null;
+ if (!key) return { ...row, unreadable: true };
+ try {
+ return {
+ ...row,
+ pem: open(key, row.pem_enc, `app:${row.app_id}:pem`),
+ webhook_secret: open(key, row.webhook_secret_enc, `app:${row.app_id}:webhook_secret`),
+ client_secret: row.client_secret_enc ? open(key, row.client_secret_enc, `app:${row.app_id}:client_secret`) : null,
+ };
+ } catch (e) {
+ log.warn(`[Run] checks: the App secrets cannot be opened (${e.message}); create the App again`);
+ return { ...row, unreadable: true };
+ }
+ }
+
+ /** An installation token, from memory or the sealed cache, refreshed five minutes before it expires. */
+ async function tokenFor(app, installationId) {
+ if (!app || app.unreadable || !app.pem) throw new Error('checks: the App has no readable key');
+ const id = Number(installationId);
+ const held = tokenCache.get(id);
+ if (held && held.expiresAt - now() > TOKEN_MARGIN_MS) return held.token;
+ const row = await store.getToken(db, id).catch(() => null);
+ if (row && Number(row.expires_at) - now() > TOKEN_MARGIN_MS) {
+ try {
+ const token = open(key, row.token_enc, `installation:${id}`);
+ tokenCache.set(id, { token, expiresAt: Number(row.expires_at) });
+ return token;
+ } catch { await store.deleteToken(db, id).catch(() => null); }
+ }
+ const issued = await github.createInstallationToken(app, id);
+ const sealed = seal(key, issued.token, `installation:${id}`);
+ await store.putToken(db, id, sealed, issued.expiresAt, now()).catch((e) => log.warn(`[Run] checks: the installation token cache could not be written (${e.code || e.message})`));
+ tokenCache.set(id, issued);
+ return issued.token;
+ }
+
+ /** Context for one plan: the App and an installation token, best effort (a missing token only loses the report). */
+ async function planContext(plan) {
+ const app = await appRow();
+ if (!app || app.unreadable) return { app, token: null };
+ if (!plan || !plan.installation_id) return { app, token: null };
+ try { return { app, token: await tokenFor(app, plan.installation_id) }; } catch (e) {
+ log.warn(`[Run] checks: no installation token for ${plan.repo} (${e.code || e.message})`);
+ return { app, token: null };
+ }
+ }
+
+ const detailsUrlOf = (runJobId) => (runJobId ? `${config.baseUrl}/api/v1/jobs/${encodeURIComponent(runJobId)}` : null);
+
+ // ── planning ───────────────────────────────────────────────────────────────
+
+ /**
+ * Plan one webhook event: read the workflows at the SHA, keep the ones whose `on` matches, expand jobs,
+ * create a plan, one queued check run per job, and queue the first stage. → { plans, errors }.
+ */
+ async function planForEvent({ repo, owner, name, sha, event, ref, branch, action = null, installationId, deliveryId = null }) {
+ // The drill/test switch: RUN_JOBS=off means the background side is not running, so a delivery is
+ // recorded and answered, but no workflow is read and no outbound call is made.
+ if (!enabled) return { plans: [], skipped: 'disabled' };
+ if (!config.jobs.enabled) return { plans: [], skipped: 'jobs_off' };
+ if (!project) {
+ if (!loggedNoProject) { log.warn('[Run] checks: RUN_CHECKS_PROJECT is not set, so no workflow is planned'); loggedNoProject = true; }
+ return { plans: [], skipped: 'no_project' };
+ }
+ if (!SHA_RE.test(String(sha || ''))) return { plans: [], skipped: 'bad_sha' };
+ const app = await appRow();
+ if (!app || app.unreadable || !app.pem) return { plans: [], skipped: 'no_app' };
+ const token = await tokenFor(app, installationId);
+ const workflows = await github.listWorkflows(app, token, { owner, repo: name, sha });
+ const readWorkflow = async (uses) => {
+ if (uses.local) return github.readFile(app, token, { owner, repo: name, path: uses.path, ref: sha });
+ if (!owners.includes(String(uses.owner || '').toLowerCase())) return null;
+ return github.readFile(app, token, { owner: uses.owner, repo: uses.repo, path: uses.path, ref: uses.ref });
+ };
+ const planned = await planWorkflows({ workflows, event, branch, action, readWorkflow, log });
+ const made = [];
+ for (const wf of planned.workflows) {
+ const plan = {
+ id: newPlanId(), delivery_id: deliveryId, repo, owner, sha, event, ref, branch,
+ installation_id: installationId, workflow_path: wf.path, workflow_name: wf.name,
+ };
+ const jobs = wf.jobs.map((job) => ({ ...job, id: newJobId(), job_key: job.key, matrix_group: job.group && job.max_parallel ? job.group : null }));
+ await db.tx((t) => store.insertPlan(t, { plan, jobs }, now()));
+ made.push(plan);
+ await ensureCheckRuns(plan, jobs, token);
+ await queueReady(plan.id);
+ }
+ return { plans: made, errors: planned.errors };
+ }
+
+ /** Create the missing GitHub check runs for a plan's jobs (queued). A GitHub failure is logged, never fatal. */
+ async function ensureCheckRuns(plan, jobs, token) {
+ if (!token) return;
+ for (const job of jobs) {
+ if (job.check_run_id || job.state === 'completed') continue;
+ try {
+ const run = await github.createCheckRun(token, {
+ owner: plan.owner, repo: plan.repo.split('/')[1], name: `openvibe / ${job.display_name}`,
+ headSha: plan.sha, detailsUrl: detailsUrlOf(job.run_job_id), externalId: job.id,
+ });
+ await store.setCheckRun(db, job.id, run.id, now());
+ } catch (e) {
+ log.warn(`[Run] checks: check run for ${plan.repo} ${job.display_name} was not created (${e.code || e.message})`);
+ }
+ }
+ }
+
+ // ── running and finishing ──────────────────────────────────────────────────
+
+ /** The conclusion a node job's result/state means, in GitHub's vocabulary. */
+ function conclusionOf(row, result) {
+ const direct = MAP_CONCLUSION[String(result && result.conclusion || '').toLowerCase()];
+ if (direct) return direct;
+ if (result && result.ok === false) return 'failure';
+ if (row.run_state === 'succeeded') return 'success';
+ if (row.run_state === 'cancelled') return 'cancelled';
+ if (row.run_state === 'expired') return row.run_exit_reason === 'ttl' ? 'timed_out' : 'failure';
+ return 'failure';
+ }
+
+ function summaryOf(job, result, row) {
+ const steps = Array.isArray(result && result.steps) ? result.steps.slice(0, 100) : [];
+ const lines = [];
+ if (steps.length) {
+ lines.push('| Step | Result | Time |', '| --- | --- | --- |');
+ for (const step of steps) {
+ const name = String(obj(step).name || 'step').replace(/\|/g, '\\|').slice(0, 200);
+ const conclusion = String(obj(step).conclusion || '').replace(/\|/g, '\\|').slice(0, 40);
+ const ms = Number(obj(step).ms);
+ lines.push(`| ${name} | ${conclusion} | ${Number.isFinite(ms) && ms >= 0 ? `${ms} ms` : ''} |`);
+ }
+ } else {
+ lines.push(`The node job ended ${row.run_state}${row.run_exit_reason ? ` (${row.run_exit_reason})` : ''}.`);
+ }
+ if (row.run_error_code) lines.push('', `Run error: \`${String(row.run_error_code).slice(0, 100)}\``);
+ return lines.join('\n').slice(0, 60_000);
+ }
+
+ /**
+ * Report one completed workflow job to GitHub, then mark it complete and write the shadow pair. A failed
+ * report is retried by later ticks (up to five times), so a short GitHub outage does not lose the check.
+ * → true when the row is complete.
+ */
+ async function finishJob(plan, job, { conclusion, summary, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null }) {
+ const at = finishedAt || now();
+ if (job.check_run_id) {
+ const { token } = await planContext(plan);
+ if (!token) {
+ log.warn(`[Run] checks: no token; ${plan.repo} ${job.display_name} completes without a check run update`);
+ } else {
+ try {
+ await github.updateCheckRun(token, {
+ owner: plan.owner, repo: plan.repo.split('/')[1], checkRunId: job.check_run_id,
+ status: 'completed', conclusion: CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure',
+ output: { title: job.display_name.slice(0, 250), summary: summary || 'Completed' },
+ detailsUrl: detailsUrlOf(job.run_job_id), completedAt: new Date(at).toISOString(),
+ });
+ } catch (e) {
+ const bumped = await store.bumpReportError(db, job.id, now());
+ const errors = Number((bumped && bumped.report_errors) || 0);
+ if (errors < 5) {
+ log.warn(`[Run] checks: check run for ${plan.repo} ${job.display_name} was not updated (${e.code || e.message}); retrying`);
+ return false;
+ }
+ log.warn(`[Run] checks: giving up on check run ${job.check_run_id} after ${errors} failures (${e.code || e.message})`);
+ }
+ }
+ }
+ const done = await store.completeJob(db, job.id, {
+ conclusion: CHECK_CONCLUSIONS.has(conclusion) ? conclusion : 'failure', summary, error,
+ queueMs, runMs, startedAt, finishedAt: at, nodeId, at,
+ });
+ if (done) {
+ await store.shadowNode(db, {
+ repo: plan.repo, sha: plan.sha, workflowName: plan.workflow_name, jobName: job.display_name,
+ checkRunId: job.check_run_id, conclusion: done.conclusion, queueMs: done.queue_ms, runMs: done.run_ms,
+ startedAt: done.started_at, finishedAt: done.finished_at, at,
+ }).catch((e) => log.warn(`[Run] checks: shadow row for ${plan.repo} failed (${e.code || e.message})`));
+ }
+ return true;
+ }
+
+ /** A job whose need did not succeed is skipped; its check run says so. */
+ async function skipJob(plan, job, reason) {
+ return finishJob(plan, job, { conclusion: 'skipped', summary: `Skipped: ${reason}`, finishedAt: now() });
+ }
+
+ /** Queue one waiting workflow job as a Run job (class code, [email protected]) in one transaction. */
+ async function queueJob(plan, job) {
+ const claimed = await store.claimQueue(db, job.id, now());
+ if (!claimed) return null;
+ const timeoutMs = Math.max(1, Number(claimed.timeout_minutes) || 30) * 60_000;
+ const matrix = parseJson(claimed.matrix);
+ const body = {
+ class: 'code',
+ artifact: { name: 'openvibe-codes', version: '1.0.0' },
+ args: {
+ kind: 'check',
+ repo: `https://github.com/${plan.repo}.git`,
+ sha: plan.sha,
+ workflow: claimed.workflow_path,
+ job: claimed.workflow_job_key,
+ ...(Object.keys(matrix || {}).length ? { matrix } : {}),
+ event: plan.event,
+ ref: plan.ref,
+ timeout_ms: timeoutMs,
+ },
+ limits: { wall_ms: timeoutMs, cpu_ms: timeoutMs, mem_bytes: 4 * 1024 ** 3 },
+ ttl_ms: Math.min(timeoutMs + 600_000, 86_400_000),
+ egress: 'public',
+ requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['first-party', 'user-owned'] },
+ };
+ const idempotencyKey = `checks:${claimed.id}:${Number(claimed.attempts) + 1}`;
+ try {
+ const out = await db.tx(async (t) => {
+ const made = await jobsStore.mint(t, body, { projectId: project, requester: 'service:checks', via: null, idempotencyKey, now: now() });
+ if (made.created && events) await events.queued(t, made.row, {});
+ await store.linkRunJob(t, claimed.id, made.row.id, now());
+ return made;
+ });
+ return out.row;
+ } catch (e) {
+ await store.resetJobToWaiting(db, claimed.id, now()).catch(() => null);
+ log.warn(`[Run] checks: could not queue ${plan.repo} ${claimed.display_name} (${e.code || e.message})`);
+ return null;
+ }
+ }
+
+ /**
+ * Advance one plan: skip what a failed/skipped need blocks, queue every whose needs succeeded (respecting
+ * matrix max-parallel), and settle the plan when every job is complete.
+ */
+ async function queueReady(planId) {
+ const plan = await store.getPlan(db, planId);
+ if (!plan || plan.state !== 'active') return { queued: 0, skipped: 0 };
+ let jobs = await store.getJobs(db, planId);
+ // A check run whose creation failed at planning is retried here before the job is queued.
+ if (jobs.some((j) => !j.check_run_id && j.state !== 'completed')) {
+ const { token } = await planContext(plan);
+ if (token) {
+ await ensureCheckRuns(plan, jobs, token);
+ jobs = await store.getJobs(db, planId);
+ }
+ }
+ const byKey = new Map(jobs.map((j) => [j.job_key, j]));
+ const activeCounts = new Map();
+ for (const j of jobs) if (j.matrix_group && (j.state === 'queued' || j.state === 'running')) activeCounts.set(j.matrix_group, (activeCounts.get(j.matrix_group) || 0) + 1);
+ let queued = 0;
+ let skipped = 0;
+ for (const job of jobs) {
+ if (job.state === 'completed' || job.run_job_id) continue;
+ const needs = (Array.isArray(job.needs) ? job.needs : []).map((k) => byKey.get(k));
+ if (needs.some((n) => !n)) {
+ if (await skipJob(plan, job, 'a job it needs is not in the plan')) {
+ byKey.set(job.job_key, { ...job, state: 'completed', conclusion: 'skipped' });
+ skipped++;
+ }
+ continue;
+ }
+ if (needs.some((n) => n.state === 'completed' && n.conclusion !== 'success')) {
+ if (await skipJob(plan, job, 'a job it needs did not succeed')) {
+ byKey.set(job.job_key, { ...job, state: 'completed', conclusion: 'skipped' });
+ skipped++;
+ }
+ continue;
+ }
+ if (needs.some((n) => n.state !== 'completed')) continue;
+ if (job.matrix_group && job.max_parallel) {
+ const active = activeCounts.get(job.matrix_group) || 0;
+ if (active >= Number(job.max_parallel)) continue;
+ activeCounts.set(job.matrix_group, active + 1);
+ }
+ const row = await queueJob(plan, job);
+ if (row) queued++;
+ }
+ jobs = await store.getJobs(db, planId);
+ if (jobs.length && jobs.every((j) => j.state === 'completed')) {
+ const failed = jobs.some((j) => j.conclusion && j.conclusion !== 'success' && j.conclusion !== 'skipped');
+ await store.setPlanState(db, planId, failed ? 'failed' : 'succeeded', now());
+ }
+ return { queued, skipped };
+ }
+
+ /** Follow every Run job of a plan: start, retry a lost node once, finish with the result's conclusion. */
+ async function syncRuns() {
+ const rows = await store.unsettledRuns(db);
+ let started = 0;
+ let finished = 0;
+ let retried = 0;
+ for (const row of rows) {
+ const plan = await store.getPlan(db, row.plan_id);
+ if (!plan) continue;
+ if (row.run_state === 'queued' || row.run_state === 'placed') continue;
+ if (row.run_state === 'running') {
+ if (row.check_state !== 'in_progress') {
+ const { token } = await planContext(plan);
+ if (token && row.check_run_id) {
+ try {
+ await github.updateCheckRun(token, {
+ owner: plan.owner, repo: plan.repo.split('/')[1], checkRunId: row.check_run_id,
+ status: 'in_progress', startedAt: new Date(row.run_started_ms || now()).toISOString(),
+ detailsUrl: detailsUrlOf(row.run_job_id),
+ });
+ } catch (e) {
+ log.warn(`[Run] checks: check run ${row.check_run_id} not marked in progress (${e.code || e.message})`);
+ continue;
+ }
+ }
+ await store.markRunning(db, row.id, now(), row.run_node_id);
+ started++;
+ }
+ continue;
+ }
+ const result = parseJson(row.run_result);
+ // A node that went away mid-job leaves no check result: retry once elsewhere before failing.
+ const lostNode = !result.conclusion && result.ok === undefined && !Array.isArray(result.steps);
+ if (lostNode && Number(row.attempts) < 2) {
+ const reset = await store.resetJobToWaiting(db, row.id, now());
+ if (reset && await queueJob(plan, reset)) retried++;
+ continue;
+ }
+ const finishedAt = Number(row.run_finished_ms) || now();
+ const startedAt = Number(row.run_started_ms) || (row.started_at != null ? Number(row.started_at) : null);
+ const conclusion = conclusionOf(row, result);
+ const ok = await finishJob(plan, row, {
+ conclusion, summary: summaryOf(row, result, row),
+ error: row.run_error_code || null,
+ queueMs: startedAt ? Math.max(0, startedAt - Number(row.created_at)) : null,
+ runMs: startedAt ? Math.max(0, finishedAt - startedAt) : null,
+ startedAt, finishedAt, nodeId: row.run_node_id,
+ });
+ if (ok) finished++;
+ }
+ return { started, finished, retried };
+ }
+
+ /** One scheduler pass: sweep old deliveries, follow the Run jobs, advance every active plan. */
+ async function tick(at = now()) {
+ if (!enabled) return { enabled: false, started: 0, finished: 0, retried: 0, queued: 0, skipped: 0, plans: 0 };
+ await store.sweepDeliveries(db, at).catch(() => null);
+ await store.sweepTokens(db, at).catch(() => null);
+ await store.resetStaleQueuing(db, at).catch(() => null);
+ const runs = await syncRuns();
+ const plans = await store.listActivePlans(db);
+ let queued = 0;
+ let skipped = 0;
+ for (const plan of plans) {
+ try {
+ const out = await queueReady(plan.id);
+ queued += out.queued;
+ skipped += out.skipped;
+ } catch (e) {
+ log.warn(`[Run] checks: plan ${plan.id} could not advance (${e.code || e.message})`);
+ }
+ }
+ return { enabled: true, ...runs, queued, skipped, plans: plans.length };
+ }
+
+ // ── webhook ────────────────────────────────────────────────────────────────
+
+ const allowedRepo = (payload) => {
+ const full = payload && payload.repository && payload.repository.full_name;
+ if (typeof full !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(full)) return false;
+ const owner = full.split('/')[0].toLowerCase();
+ if (!owners.includes(owner)) return false;
+ const account = payload.installation && payload.installation.account && payload.installation.account.login;
+ if (account && !owners.includes(String(account).toLowerCase())) return false;
+ return true;
+ };
+
+ async function rememberInstallation(payload, at) {
+ const inst = payload && payload.installation;
+ if (!inst || !inst.id) return;
+ const account = inst.account || {};
+ if (!owners.includes(String(account.login || '').toLowerCase())) return;
+ await store.upsertInstallation(db, {
+ installationId: Number(inst.id), login: account.login, accountId: account.id == null ? null : Number(account.id),
+ accountType: account.type || null, suspendedAt: inst.suspended_at ? Date.parse(inst.suspended_at) : null,
+ }, at);
+ }
+
+ async function handleEvent(eventName, payload, deliveryId) {
+ if (eventName === 'installation') {
+ if (payload.action === 'deleted') {
+ const inst = payload.installation;
+ if (inst && inst.id) await db.maybe('DELETE FROM run_checks_installations WHERE installation_id = $1', [Number(inst.id)]);
+ if (inst && inst.id) await store.deleteToken(db, Number(inst.id)).catch(() => null);
+ } else {
+ await rememberInstallation(payload, now());
+ }
+ return { installation: true };
+ }
+ if (eventName === 'workflow_job') {
+ if (payload.action !== 'completed' || !allowedRepo(payload)) return {};
+ const job = obj(payload.workflow_job);
+ if (!SHA_RE.test(String(job.head_sha || '')) || !job.name || !job.conclusion) return {};
+ await store.shadowActions(db, {
+ repo: payload.repository.full_name, sha: job.head_sha,
+ workflowName: job.workflow_name || '', jobName: String(job.name),
+ conclusion: MAP_CONCLUSION[String(job.conclusion).toLowerCase()] || (CHECK_CONCLUSIONS.has(job.conclusion) ? job.conclusion : 'failure'),
+ startedAt: job.started_at ? Date.parse(job.started_at) : null,
+ finishedAt: job.completed_at ? Date.parse(job.completed_at) : null, at: now(),
+ });
+ return { shadow: true };
+ }
+ if (eventName === 'push') {
+ if (!allowedRepo(payload)) return {};
+ const branch = payload.repository.default_branch;
+ if (payload.ref !== `refs/heads/${branch}`) return {};
+ if (!SHA_RE.test(String(payload.after || ''))) return {};
+ await rememberInstallation(payload, now());
+ const [repoOwner, repoName] = payload.repository.full_name.split('/');
+ return planForEvent({
+ repo: payload.repository.full_name, owner: repoOwner, name: repoName,
+ sha: payload.after, event: 'push', ref: payload.ref, branch,
+ installationId: payload.installation && payload.installation.id, deliveryId,
+ });
+ }
+ if (eventName === 'pull_request') {
+ const action = payload.action;
+ if (!['opened', 'synchronize', 'reopened'].includes(action) || !allowedRepo(payload)) return {};
+ const pr = obj(payload.pull_request);
+ const head = obj(pr.head);
+ const headRepo = obj(head.repo);
+ // A fork's workflow must never be read or run for a base repo (a stranger's commit is not our code).
+ if (!headRepo.full_name || !allowedRepo({ repository: headRepo, installation: payload.installation })) return {};
+ if (!SHA_RE.test(String(head.sha || ''))) return {};
+ const branch = obj(pr.base).ref;
+ await rememberInstallation(payload, now());
+ const [repoOwner, repoName] = payload.repository.full_name.split('/');
+ return planForEvent({
+ repo: payload.repository.full_name, owner: repoOwner, name: repoName,
+ sha: head.sha, event: 'pull_request', ref: `refs/pull/${pr.number}/merge`, branch, action,
+ installationId: payload.installation && payload.installation.id, deliveryId,
+ });
+ }
+ return {};
+ }
+
+ async function webhook(req, res) {
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body || ''));
+ const app = await appRow();
+ if (!app || app.unreadable || !app.webhook_secret) return res.status(503).json({ error: 'checks.not_configured' });
+ const signature = String(req.get('x-hub-signature-256') || '');
+ const expected = `sha256=${crypto.createHmac('sha256', app.webhook_secret).update(raw).digest('hex')}`;
+ if (!safeEqual(signature, expected)) return res.status(401).json({ error: 'invalid signature' });
+ const deliveryId = String(req.get('x-github-delivery') || '').trim();
+ if (!deliveryId) return res.status(400).json({ error: 'missing X-GitHub-Delivery' });
+ const eventName = String(req.get('x-github-event') || '');
+ let payload;
+ try { payload = JSON.parse(raw.toString('utf8') || '{}'); } catch { return res.status(400).json({ error: 'invalid JSON' }); }
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return res.status(400).json({ error: 'invalid payload' });
+ const at = now();
+ const fresh = await store.claimDelivery(db, { deliveryId, event: eventName, at, expiresAt: at + DELIVERY_TTL_MS });
+ if (!fresh) return res.status(204).end();
+ try {
+ await handleEvent(eventName, payload, deliveryId);
+ await store.sweepDeliveries(db, at).catch(() => null);
+ return res.status(204).end();
+ } catch (e) {
+ log.warn(`[Run] checks: webhook ${eventName} failed (${e.code || e.message})`);
+ await store.releaseDelivery(db, deliveryId).catch(() => null);
+ return res.status(500).json({ error: 'checks.webhook_failed' });
+ }
+ }
+
+ // ── the owner page ─────────────────────────────────────────────────────────
+
+ function stateCookie(req) {
+ const value = String((req.cookies && req.cookies.ov_checks_state) || '');
+ const at = value.indexOf('.');
+ if (at < 1) return { state: null, subject: null };
+ return { state: value.slice(0, at), subject: value.slice(at + 1) };
+ }
+
+ const admin = express.Router();
+ admin.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ admin.get('/', ownerPage, async (req, res, next) => {
+ try {
+ const app = await appRow();
+ const state = crypto.randomBytes(24).toString('base64url');
+ const subject = String((req.user && (req.user.subject_id || req.user.sub)) || '');
+ res.cookie('ov_checks_state', `${state}.${subject}`, {
+ httpOnly: true, sameSite: 'lax', secure: Boolean(config.isProduction), path: '/admin/checks', maxAge: 10 * 60_000,
+ });
+ let installations = await store.listInstallations(db).catch(() => []);
+ // Refresh the account list from GitHub when the App is readable; a failed read leaves the stored list.
+ if (app && !app.unreadable && config.jobs.enabled) {
+ try {
+ const list = await github.listInstallations(app);
+ for (const inst of list) {
+ const account = obj(inst.account);
+ if (!owners.includes(String(account.login || '').toLowerCase())) continue;
+ await store.upsertInstallation(db, {
+ installationId: Number(inst.id), login: account.login, accountId: account.id == null ? null : Number(account.id),
+ accountType: account.type || null, suspendedAt: inst.suspended_at ? Date.parse(inst.suspended_at) : null,
+ }, now());
+ }
+ installations = await store.listInstallations(db);
+ } catch (e) { log.warn(`[Run] checks: installations could not be read (${e.code || e.message})`); }
+ }
+ const manifest = manifestFor({ baseUrl: config.baseUrl });
+ const action = `${githubWeb}/organizations/${encodeURIComponent(org)}/settings/apps/new?state=${encodeURIComponent(state)}`;
+ const warnings = [];
+ if (!key) warnings.push('RUN_SECRETS_KEY is not set: the App cannot be stored.');
+ if (!project) warnings.push('RUN_CHECKS_PROJECT is not set: no check is planned until it is.');
+ if (app && app.unreadable) warnings.push('The stored App secrets cannot be opened with this RUN_SECRETS_KEY. Create the App again.');
+ res.type('html').send(views.adminPage({
+ app, installations, action, manifest: JSON.stringify(manifest), created: req.query.created === '1',
+ warnings, org, keyConfigured: Boolean(key), projectConfigured: Boolean(project),
+ installUrl: app && app.slug ? `${githubWeb}/apps/${encodeURIComponent(app.slug)}/installations/new` : null,
+ }));
+ } catch (e) { next(e); }
+ });
+
+ admin.get('/created', ownerPage, async (req, res, next) => {
+ try {
+ if (!key) return res.status(503).type('html').send(views.errorPage({ title: 'Encryption is not installed', text: 'Set RUN_SECRETS_KEY and try again.' }));
+ const cookie = stateCookie(req);
+ const state = typeof req.query.state === 'string' ? req.query.state : '';
+ const subject = String((req.user && (req.user.subject_id || req.user.sub)) || '');
+ res.clearCookie('ov_checks_state', { path: '/admin/checks' });
+ if (!cookie.state || !safeEqual(cookie.state, state) || cookie.subject !== subject) {
+ return res.status(400).type('html').send(views.errorPage({ title: 'The state did not match', text: 'Start again from the checks page.' }));
+ }
+ if (req.query.error) {
+ return res.status(400).type('html').send(views.errorPage({ title: 'GitHub did not create the App', text: String(req.query.error_description || req.query.error).slice(0, 300) }));
+ }
+ const code = typeof req.query.code === 'string' ? req.query.code : '';
+ if (!code) return res.status(400).type('html').send(views.errorPage({ title: 'No code', text: 'GitHub returned no manifest code.' }));
+ let converted;
+ try { converted = await github.convertManifest(code); } catch (e) {
+ log.warn(`[Run] checks: manifest conversion failed (${e.code || e.message})`);
+ return res.status(502).type('html').send(views.errorPage({ title: 'GitHub did not return the App', text: 'The manifest code was not exchanged. Try creating the App again.' }));
+ }
+ const at = now();
+ const appId = Number(converted.id);
+ await store.putApp(db, {
+ app_id: appId, slug: String(converted.slug || ''), name: String(converted.name || 'OpenVibe Checks'),
+ html_url: converted.html_url || null,
+ pem_enc: seal(key, converted.pem, `app:${appId}:pem`),
+ webhook_secret_enc: seal(key, converted.webhook_secret, `app:${appId}:webhook_secret`),
+ client_id: converted.client_id || null,
+ client_secret_enc: converted.client_secret ? seal(key, converted.client_secret, `app:${appId}:client_secret`) : null,
+ }, at);
+ // A replacement App invalidates every token and account row of the old registration.
+ await store.clearTokens(db).catch(() => null);
+ await store.clearInstallations(db).catch(() => null);
+ tokenCache.clear();
+ res.redirect(303, '/admin/checks?created=1');
+ } catch (e) { next(e); }
+ });
+
+ // ── the owner's agreement query ────────────────────────────────────────────
+
+ async function agreementQuery(req, res) {
+ const repo = typeof req.query.repo === 'string' ? req.query.repo.trim() : '';
+ if (repo && !/^[^/\s]+\/[^/\s]+$/.test(repo)) return res.status(400).json({ error: 'repo must be owner/name' });
+ const repos = await store.agreement(db, repo || null);
+ res.json({ repos });
+ }
+
+ /** GET /api/v1/checks/agreement: owner session only, like the page that shows it. */
+ function agreement(req, res, next) {
+ ownerApi(req, res, () => { agreementQuery(req, res).catch(next); });
+ }
+
+ function start() {
+ if (!enabled || !config.jobs.enabled || timer) return false;
+ timer = setInterval(() => { tick().catch((e) => log.warn(`[Run] checks: tick failed (${e.code || e.message})`)); }, tickMs);
+ timer.unref?.();
+ return true;
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return {
+ admin, webhook, agreement, tick, start, stop, planForEvent, queueReady, manifestFor,
+ isOwner, detailsUrlOf, keyConfigured: Boolean(key), projectConfigured: Boolean(project),
+ status: () => ({
+ enabled, note: !key ? 'off: RUN_SECRETS_KEY is not set' : cfg.enabled === false ? 'off: RUN_CHECKS=off' : !project ? 'on, but RUN_CHECKS_PROJECT is not set so nothing is planned' : 'on',
+ org, owners, tick_ms: tickMs, project_set: Boolean(project), running: Boolean(timer),
+ }),
+ deps: { config, db, github, store, owners, project, enabled },
+ };
+}
+
+module.exports = { createChecks, manifestFor, safeEqual };
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+'use strict';
+/**
+ * The checks scheduler's rows (migrations/0020_checks.sql), row access only. A state change that also emits a
+ * run.job.* event happens in the caller's transaction; the scheduler's own claim/complete updates are
+ * conditional UPDATEs, so two ticks (or two processes) cannot queue the same workflow job twice or report a
+ * check run twice.
+ */
+const json = (v) => (v == null ? null : JSON.stringify(v));
+const num = (v) => (v == null ? null : Number(v));
+
+// ── App ──────────────────────────────────────────────────────────────────────
+
+const getApp = (db) => db.maybe('SELECT * FROM run_checks_app WHERE id = 1');
+
+async function putApp(db, row, at) {
+ return db.maybe(
+ `INSERT INTO run_checks_app (id, app_id, slug, name, html_url, pem_enc, webhook_secret_enc, client_id, client_secret_enc, created_at, updated_at)
+ VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
+ ON CONFLICT (id) DO UPDATE SET app_id = EXCLUDED.app_id, slug = EXCLUDED.slug, name = EXCLUDED.name,
+ html_url = EXCLUDED.html_url, pem_enc = EXCLUDED.pem_enc, webhook_secret_enc = EXCLUDED.webhook_secret_enc,
+ client_id = EXCLUDED.client_id, client_secret_enc = EXCLUDED.client_secret_enc, updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [row.app_id, row.slug, row.name, row.html_url || null, row.pem_enc, row.webhook_secret_enc, row.client_id || null, row.client_secret_enc || null, at]);
+}
+
+const deleteApp = (db) => db.maybe('DELETE FROM run_checks_app WHERE id = 1 RETURNING id');
+
+// ── installation tokens ─────────────────────────────────────────────────────
+
+const getToken = (db, installationId) => db.maybe('SELECT * FROM run_checks_tokens WHERE installation_id = $1', [installationId]);
+
+const putToken = (db, installationId, tokenEnc, expiresAt, at) => db.maybe(
+ `INSERT INTO run_checks_tokens (installation_id, token_enc, expires_at, updated_at) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (installation_id) DO UPDATE SET token_enc = EXCLUDED.token_enc, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [installationId, tokenEnc, expiresAt, at]);
+
+const deleteToken = (db, installationId) => db.maybe('DELETE FROM run_checks_tokens WHERE installation_id = $1 RETURNING installation_id', [installationId]);
+const clearTokens = (db) => db.maybe('DELETE FROM run_checks_tokens RETURNING installation_id');
+const sweepTokens = (db, at) => db.maybe('DELETE FROM run_checks_tokens WHERE expires_at <= $1 RETURNING installation_id', [at]);
+
+// ── installations ───────────────────────────────────────────────────────────
+
+async function upsertInstallation(db, { installationId, login, accountId = null, accountType = null, suspendedAt = null }, at) {
+ return db.maybe(
+ `INSERT INTO run_checks_installations (installation_id, account_login, account_id, account_type, suspended_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $6)
+ ON CONFLICT (installation_id) DO UPDATE SET account_login = EXCLUDED.account_login, account_id = EXCLUDED.account_id,
+ account_type = EXCLUDED.account_type, suspended_at = EXCLUDED.suspended_at, updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [installationId, login, accountId, accountType, suspendedAt, at]);
+}
+
+const listInstallations = (db) => db.many('SELECT * FROM run_checks_installations ORDER BY account_login, installation_id');
+const clearInstallations = (db) => db.maybe('DELETE FROM run_checks_installations RETURNING installation_id');
+
+// ── deliveries ──────────────────────────────────────────────────────────────
+
+/** Claim one X-GitHub-Delivery. → true when it was new, false when it is a replay. */
+async function claimDelivery(db, { deliveryId, event, at, expiresAt }) {
+ const row = await db.maybe(
+ `INSERT INTO run_checks_deliveries (delivery_id, event, received_at, expires_at) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (delivery_id) DO NOTHING RETURNING delivery_id`,
+ [deliveryId, event, at, expiresAt]);
+ return Boolean(row);
+}
+
+const releaseDelivery = (db, deliveryId) => db.maybe('DELETE FROM run_checks_deliveries WHERE delivery_id = $1 RETURNING delivery_id', [deliveryId]);
+
+const sweepDeliveries = (db, at) => db.maybe('DELETE FROM run_checks_deliveries WHERE expires_at <= $1', [at]);
+
+// ── plans and jobs ──────────────────────────────────────────────────────────
+
+/** Insert a plan and its flat jobs in the caller's transaction. */
+async function insertPlan(t, { plan, jobs }, at) {
+ const row = await t.maybe(
+ `INSERT INTO run_checks_plans (id, delivery_id, repo, repo_owner, sha, event, ref, branch, installation_id, workflow_path, workflow_name, state, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12, $12) RETURNING *`,
+ [plan.id, plan.delivery_id || null, plan.repo, plan.owner, plan.sha, plan.event, plan.ref, plan.branch || null,
+ plan.installation_id || null, plan.workflow_path, plan.workflow_name, at]);
+ for (const job of jobs) {
+ await t.maybe(
+ `INSERT INTO run_checks_jobs (id, plan_id, job_key, job_name, display_name, workflow_path, workflow_job_key, stage, needs, matrix,
+ matrix_group, max_parallel, timeout_minutes, check_state, state, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, 'queued', 'waiting', $14, $14)`,
+ [job.id, plan.id, job.job_key, job.job_name, job.display_name, job.workflow_path, job.workflow_job_key, job.stage,
+ JSON.stringify(job.needs || []), json(job.matrix), job.matrix_group || null, job.max_parallel || null,
+ job.timeout_minutes, at]);
+ }
+ return row;
+}
+
+const getPlan = (db, id) => db.maybe('SELECT *, repo_owner AS owner FROM run_checks_plans WHERE id = $1', [id]);
+const getPlanByDelivery = (db, deliveryId) => db.many('SELECT *, repo_owner AS owner FROM run_checks_plans WHERE delivery_id = $1 ORDER BY created_at, id', [deliveryId]);
+const listActivePlans = (db, limit = 50) => db.many(`SELECT *, repo_owner AS owner FROM run_checks_plans WHERE state = 'active' ORDER BY created_at, id LIMIT $1`, [limit]);
+
+const setPlanState = (db, id, state, at) => db.maybe(
+ `UPDATE run_checks_plans SET state = $2, updated_at = $3 WHERE id = $1 AND state = 'active' RETURNING *`, [id, state, at]);
+
+const getJobs = (db, planId) => db.many('SELECT * FROM run_checks_jobs WHERE plan_id = $1 ORDER BY stage, job_key', [planId]);
+const getJob = (db, id) => db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [id]);
+
+/** A tick claims a waiting job before it mints a Run job; two ticks can never mint two. */
+const claimQueue = (db, id, at) => db.maybe(
+ `UPDATE run_checks_jobs SET state = 'queuing', updated_at = $2 WHERE id = $1 AND state = 'waiting' AND run_job_id IS NULL RETURNING *`, [id, at]);
+
+const resetJobToWaiting = (db, id, at) => db.maybe(
+ `UPDATE run_checks_jobs SET state = 'waiting', run_job_id = NULL, check_state = 'queued', started_at = NULL, updated_at = $2
+ WHERE id = $1 AND state <> 'completed' RETURNING *`, [id, at]);
+
+/** Link the Run job a claim minted (in the same transaction as the mint when possible). */
+const linkRunJob = (t, id, runJobId, at) => t.maybe(
+ `UPDATE run_checks_jobs SET run_job_id = $2, state = 'queued', attempts = attempts + 1, queued_at = COALESCE(queued_at, $3), updated_at = $3
+ WHERE id = $1 RETURNING *`, [id, runJobId, at]);
+
+/** A crash between claim and mint leaves `queuing` behind: a later tick may retry it. */
+const resetStaleQueuing = (db, at, staleMs = 5 * 60_000) => db.many(
+ `UPDATE run_checks_jobs SET state = 'waiting', updated_at = $1 WHERE state = 'queuing' AND updated_at < $2 RETURNING id`, [at, at - staleMs]);
+
+const markRunning = (db, id, at, nodeId = null) => db.maybe(
+ `UPDATE run_checks_jobs SET state = 'running', check_state = 'in_progress', started_at = COALESCE(started_at, $2), node_id = COALESCE($3, node_id), updated_at = $2
+ WHERE id = $1 AND state IN ('queued', 'running') RETURNING *`, [id, at, nodeId]);
+
+const setCheckRun = (db, id, checkRunId, at) => db.maybe(
+ `UPDATE run_checks_jobs SET check_run_id = $2, updated_at = $3 WHERE id = $1 RETURNING *`, [id, checkRunId, at]);
+
+const bumpReportError = (db, id, at) => db.maybe(
+ `UPDATE run_checks_jobs SET report_errors = report_errors + 1, updated_at = $2 WHERE id = $1 RETURNING *`, [id, at]);
+
+/** Complete once: the condition makes a second tick (or a second process) a quiet no-op. */
+const completeJob = (db, id, { conclusion, summary = null, error = null, queueMs = null, runMs = null, startedAt = null, finishedAt = null, nodeId = null, at }) => db.maybe(
+ `UPDATE run_checks_jobs SET state = 'completed', check_state = 'completed', conclusion = $2, summary = $3, error = $4,
+ queue_ms = COALESCE($5, queue_ms), run_ms = COALESCE($6, run_ms), started_at = COALESCE(started_at, $7),
+ finished_at = $8, node_id = COALESCE($9, node_id), updated_at = $8
+ WHERE id = $1 AND state <> 'completed' RETURNING *`,
+ [id, conclusion, summary, error, queueMs, runMs, startedAt, finishedAt, nodeId]);
+
+/** The Run jobs whose check run has not been settled yet, with the Run row beside them. */
+const unsettledRuns = (db) => db.many(
+ `SELECT cj.*, rj.state AS run_state, rj.result AS run_result, rj.started_ms AS run_started_ms, rj.finished_ms AS run_finished_ms,
+ rj.node_id AS run_node_id, rj.exit_reason AS run_exit_reason, rj.error_code AS run_error_code
+ FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id
+ WHERE cj.state IN ('queued', 'running') AND cj.check_state <> 'completed'
+ ORDER BY cj.updated_at, cj.id LIMIT 500`);
+
+// ── shadow ──────────────────────────────────────────────────────────────────
+
+async function shadowNode(db, { repo, sha, workflowName = '', jobName, checkRunId = null, conclusion, queueMs = null, runMs = null, startedAt = null, finishedAt = null, at }) {
+ const hit = await db.maybe(
+ `UPDATE run_checks_shadow SET node_conclusion = $5, check_run_id = COALESCE($6, check_run_id), queue_ms = $7, run_ms = $8,
+ node_started_at = $9, node_finished_at = $10, updated_at = $11
+ WHERE repo = $1 AND sha = $2 AND job_name = $3 AND ($4 = '' OR workflow_name = '' OR workflow_name = $4)
+ RETURNING *`,
+ [repo, sha, jobName, workflowName || '', conclusion, checkRunId, queueMs, runMs, startedAt, finishedAt, at]);
+ if (hit) return hit;
+ return db.maybe(
+ `INSERT INTO run_checks_shadow (repo, sha, workflow_name, job_name, check_run_id, node_conclusion, queue_ms, run_ms, node_started_at, node_finished_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING *`,
+ [repo, sha, workflowName || '', jobName, checkRunId, conclusion, queueMs, runMs, startedAt, finishedAt, at]);
+}
+
+async function shadowActions(db, { repo, sha, workflowName = '', jobName, conclusion, startedAt = null, finishedAt = null, at }) {
+ const hit = await db.maybe(
+ `UPDATE run_checks_shadow SET actions_conclusion = $5, actions_started_at = $6, actions_finished_at = $7, updated_at = $8,
+ workflow_name = CASE WHEN workflow_name = '' THEN $4 ELSE workflow_name END
+ WHERE repo = $1 AND sha = $2 AND job_name = $3 AND ($4 = '' OR workflow_name = '' OR workflow_name = $4)
+ RETURNING *`,
+ [repo, sha, jobName, workflowName || '', conclusion, startedAt, finishedAt, at]);
+ if (hit) return hit;
+ return db.maybe(
+ `INSERT INTO run_checks_shadow (repo, sha, workflow_name, job_name, actions_conclusion, actions_started_at, actions_finished_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING *`,
+ [repo, sha, workflowName || '', jobName, conclusion, startedAt, finishedAt, at]);
+}
+
+/** Agreement per repo: paired rows, agreed rows, and the node's average queue/run time. */
+async function agreement(db, repo = null) {
+ const rows = await db.many(
+ `SELECT repo,
+ COUNT(*)::int AS total,
+ COUNT(*) FILTER (WHERE node_conclusion IS NOT NULL AND actions_conclusion IS NOT NULL)::int AS paired,
+ COUNT(*) FILTER (WHERE node_conclusion IS NOT NULL AND actions_conclusion IS NOT NULL AND node_conclusion = actions_conclusion)::int AS agreed,
+ AVG(queue_ms) AS queue_avg, AVG(run_ms) AS run_avg
+ FROM run_checks_shadow
+ WHERE ($1::text IS NULL OR repo = $1)
+ GROUP BY repo ORDER BY repo`,
+ [repo || null]);
+ return rows.map((r) => ({
+ repo: r.repo,
+ checks: Number(r.total || 0),
+ paired: Number(r.paired || 0),
+ agreed: Number(r.agreed || 0),
+ agreement: Number(r.paired || 0) ? Number(r.agreed || 0) / Number(r.paired) : null,
+ queue_ms: num(r.queue_avg),
+ run_ms: num(r.run_avg),
+ }));
+}
+
+module.exports = {
+ getApp, putApp, deleteApp,
+ getToken, putToken, deleteToken, clearTokens, sweepTokens,
+ upsertInstallation, listInstallations, clearInstallations,
+ claimDelivery, releaseDelivery, sweepDeliveries,
+ insertPlan, getPlan, getPlanByDelivery, listActivePlans, setPlanState,
+ getJobs, getJob, claimQueue, resetJobToWaiting, linkRunJob, resetStaleQueuing,
+ markRunning, setCheckRun, bumpReportError, completeJob, unsettledRuns,
+ shadowNode, shadowActions, agreement,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * The owner-only checks page. It shows the App's state, never one of its secrets: the manifest is public
+ * configuration, and the id, slug, account names and whether the App exists are the whole answer. No PEM,
+ * webhook secret, client secret or installation token is interpolated here.
+ */
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const PAGE = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)} · OpenVibe Run</title><style>:root{color-scheme:light dark;font:16px/1.6 system-ui}body{margin:0;background:light-dark(#f6f8fa,#101519);color:light-dark(#16232c,#e6edf2)}main{max-width:960px;margin:auto;padding:24px}h1{font-size:2rem;line-height:1.2}h2{margin-top:2rem}section{padding:20px;margin:16px 0;border:1px solid #70809060;border-radius:12px}a{color:light-dark(#075a85,#87d6ff)}code{overflow-wrap:anywhere}.badge{display:inline-block;padding:2px 10px;border-radius:999px;border:1px solid #70809080;font-size:.9rem}.warn{padding:12px 16px;border-radius:8px;background:#b4530920;border:1px solid #b4530980}.ok{padding:12px 16px;border-radius:8px;background:#15803d20;border:1px solid #15803d80}button{font:inherit;min-height:44px;padding:8px 16px;border-radius:8px;border:1px solid #70809080;cursor:pointer}table{display:block;overflow-x:auto;border-collapse:collapse;max-width:100%;text-align:left}th,td{padding:8px 10px;border-bottom:1px solid #70809060;white-space:nowrap}ul{padding-left:22px}@media(max-width:600px){main{padding:16px}h1{font-size:1.7rem}}</style></head><body><main>${body}</main></body></html>`;
+
+function errorPage({ title = 'Checks', text, back = '/admin/checks' } = {}) {
+ return PAGE(title, `<h1>${esc(title)}</h1><section><p>${esc(text || 'That did not work.')}</p><p><a href="${esc(back)}">Back to checks</a></p></section>`);
+}
+
+function adminPage({ app = null, installations = [], action, manifest, created = false, warnings = [], installUrl = null, keyConfigured = true, projectConfigured = true, org = 'OpenVibers' } = {}) {
+ const state = app ? (installations.length ? 'installed' : 'created') : 'none';
+ const rows = installations.map((i) => `<tr><td>${esc(i.account_login)}</td><td>${esc(i.account_type || '')}</td><td>${esc(i.installation_id)}</td><td>${i.suspended_at ? 'suspended' : 'active'}</td></tr>`).join('');
+ return PAGE('GitHub checks', `
+<h1>GitHub checks</h1>
+<p>Checks on shared nodes: a workflow job from an <strong>${esc(org)}</strong> repository is planned here and run as a
+Run job on a node, with a GitHub check run beside it. The App is the only way Run reads workflows and reports its work.</p>
+<section>
+ <p>App state: <span class="badge">${esc(state)}</span>${app ? ` · <strong>${esc(app.name)}</strong> (id ${esc(app.app_id)}, slug ${esc(app.slug)})` : ''}</p>
+ ${created ? '<p class="ok">The App was created and its key, webhook secret and client secret are stored encrypted. Install it on the organization next.</p>' : ''}
+ ${warnings.map((w) => `<p class="warn">${esc(w)}</p>`).join('')}
+ ${!keyConfigured ? '<p>Set <code>RUN_SECRETS_KEY</code> (32 bytes, base64 or 64 hex) before creating the App: without it Run cannot store the App\'s secrets.</p>' : ''}
+ ${!projectConfigured ? '<p>Set <code>RUN_CHECKS_PROJECT</code> to the project check jobs belong to; no check is planned until it is set.</p>' : ''}
+</section>
+${app ? `<section>
+ <h2>Installed on</h2>
+ ${rows ? `<table><tr><th>Account</th><th>Type</th><th>Installation</th><th>State</th></tr>${rows}</table>` : '<p>Not installed on an account yet.</p>'}
+ ${installUrl ? `<p><a href="${esc(installUrl)}">Install ${esc(app.name)} on ${esc(org)}</a></p>` : ''}
+ <p>Webhook: <code>/webhooks/github</code> · callback: <code>/admin/checks/created</code></p>
+</section>` : ''}
+<section>
+ <h2>${app ? 'Replace the GitHub App' : 'Create the GitHub App'}</h2>
+ <p>GitHub registers the App from this manifest and returns its id, private key and webhook secret once. Those are stored
+ encrypted; they are never shown here, logged or sent to a node.</p>
+ <form method="post" action="${esc(action)}">
+ <input type="hidden" name="manifest" value="${esc(manifest)}">
+ <button type="submit"${keyConfigured ? '' : ' disabled'}>${app ? 'Create a replacement App' : 'Create the GitHub App'}</button>
+ </form>
+</section>`);
+}
+
+module.exports = { adminPage, errorPage, PAGE, esc };
@@ -138,6 +138,27 @@ function loadConfig(env = process.env) {
@@ -138,6 +138,27 @@ function loadConfig(env = process.env) {
maxPerTick: Math.max(1, int(env.RUN_DISPATCH_MAX_PER_TICK, 200)),
maxPerTick: Math.max(1, int(env.RUN_DISPATCH_MAX_PER_TICK, 200)),
},
},
+ // The checks scheduler (research-2026-11-13/shared-node-checks.md): the GitHub App made by the manifest
+ // flow, the webhook and the workflow jobs Run plans from it. RUN_SECRETS_KEY seals the App's private key,
+ // webhook secret and installation tokens at rest (server/checks/crypto.js); without it the page and the
+ // webhook stay off. RUN_CHECKS_PROJECT is the project a check job belongs to. RUN_CHECKS=off turns the
+ // scheduler off; the tick only runs when the background jobs are on (RUN_JOBS).
+ checks: {
+ enabled: env.RUN_CHECKS !== 'off',
+ secretsKey: String(env.RUN_SECRETS_KEY || ''),
+ project: String(env.RUN_CHECKS_PROJECT || '').trim(),
+ owners: String(env.RUN_CHECKS_OWNERS || 'OpenVibers').split(',').map((s) => s.trim()).filter(Boolean),
+ org: env.RUN_CHECKS_ORG || 'OpenVibers',
+ // The network owner: their subject (RUN_CHECKS_OWNER, else the compute owner) or the Network owner's
+ // username (the same OWNER_USERNAME Network's owner guard uses).
+ owner: env.RUN_CHECKS_OWNER || env.RUN_COMPUTE_OWNER || '',
+ ownerUsername: env.OWNER_USERNAME || 'goosely',
+ githubApi: trim(env.RUN_CHECKS_GITHUB_API || 'https://api.github.com'),
+ githubWeb: trim(env.RUN_CHECKS_GITHUB_WEB || 'https://github.com'),
+ tickMs: Math.max(500, int(env.RUN_CHECKS_TICK_MS, 5000)),
+ requestTimeoutMs: Math.max(1000, int(env.RUN_CHECKS_TIMEOUT_MS, 8000)),
+ },
+
// Placement A1: Bench's quality priors, cached an hour, used by place() when the option is a model Bench has
// Placement A1: Bench's quality priors, cached an hour, used by place() when the option is a model Bench has
// seen. Off in tests unless asked for, so a unit test never reaches the real Bench.
// seen. Off in tests unless asked for, so a unit test never reaches the real Bench.
bench: {
bench: {
@@ -39,7 +39,7 @@ async function main() {
@@ -39,7 +39,7 @@ async function main() {
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
const app = createApp({ config, db, valkey, registry, accountData, accountSend });
const app = createApp({ config, db, valkey, registry, accountData, accountSend });
await app.locals.initComputeSettings();
await app.locals.initComputeSettings();
- const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker } = app.locals;
+ const { keys, outbox, stream, tickets, dispatch, indexnow, sessions, broker, checks } = app.locals;
keys.start();
keys.start();
const ticketKey = tickets.start();
const ticketKey = tickets.start();
@@ -52,6 +52,8 @@ async function main() {
@@ -52,6 +52,8 @@ async function main() {
// Run sessions (server/sessions/): place, watch and meter the open ones. Off without the Bot token.
// Run sessions (server/sessions/): place, watch and meter the open ones. Off without the Bot token.
sessions.start();
sessions.start();
broker.start();
broker.start();
+ // The checks scheduler (server/checks/): follow the Run jobs it planned and report their check runs.
+ checks.start();
}
}
let accountSubscription = null;
let accountSubscription = null;
@@ -90,6 +92,7 @@ async function main() {
@@ -90,6 +92,7 @@ async function main() {
() => keys.stop(),
() => keys.stop(),
() => tickets.stop(),
() => tickets.stop(),
() => dispatch.stop(),
() => dispatch.stop(),
+ () => checks.stop(),
() => broker.stop(),
() => broker.stop(),
() => sessions.stop(),
() => sessions.stop(),
() => stream.close(),
() => stream.close(),
new file mode 100644
new file mode 100644
@@ -0,0 +1,516 @@
@@ -0,0 +1,516 @@
+'use strict';
+/**
+ * The checks scheduler (research-2026-11-13/shared-node-checks.md, piece 2). Every GitHub call goes to a
+ * stub through an injected fetch; no test reaches github.com. The signature, delivery dedupe, allow-list,
+ * planner, check-run calls, manifest exchange + sealed secrets, owner gate, agreement query and the
+ * lost-node retry are exercised end to end through the app's real routes.
+ *
+ * node test/checks.test.js
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+const { planWorkflows, eventMatches, matrixCombinations } = require('../server/checks/planner');
+const { keyOf, seal, open } = require('../server/checks/crypto');
+
+const SECRETS_KEY = crypto.randomBytes(32).toString('base64');
+const PROJECT = ids.newId('project');
+const SHA = 'a'.repeat(40);
+const WEBHOOK_SECRET = 'test-webhook-secret';
+const APP_ID = 4242;
+
+const jsonResponse = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+/** A GitHub API stub: workflows at a SHA, check runs, the installation token and the manifest conversion. */
+function startGitHubStub() {
+ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const conversion = {
+ id: APP_ID, slug: 'openvibe-checks', name: 'OpenVibe Checks', html_url: 'https://github.test/apps/openvibe-checks',
+ pem, webhook_secret: WEBHOOK_SECRET, client_id: 'Iv1.checks', client_secret: 'test-client-secret-not-real',
+ };
+ const files = new Map();
+ const calls = [];
+ const checkRuns = new Map();
+ let nextCheckRun = 5000;
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(String(url));
+ if (u.origin !== 'https://github.test') return globalThis.fetch(url, init);
+ const method = init.method || 'GET';
+ let body = null;
+ if (init.body) { try { body = JSON.parse(init.body); } catch { body = String(init.body); } }
+ calls.push({ method, path: u.pathname + u.search, body, authorization: String((init.headers && init.headers.Authorization) || '') });
+
+ if (method === 'POST' && u.pathname === '/app-manifests/code-ok/conversions') return jsonResponse(200, conversion);
+ if (method === 'POST' && u.pathname === '/app-manifests/code-bad/conversions') return jsonResponse(500, { message: 'boom' });
+ if (method === 'GET' && u.pathname === '/app/installations') return jsonResponse(200, [{ id: 42, account: { login: 'OpenVibers', id: 7, type: 'Organization' }, suspended_at: null }]);
+ if (method === 'POST' && u.pathname === '/app/installations/42/access_tokens') return jsonResponse(201, { token: 'test-installation-token-not-real', expires_at: new Date(Date.now() + 3600e3).toISOString() });
+ if (method === 'GET' && u.pathname === '/repos/OpenVibers/demo/contents/.github/workflows') {
+ return jsonResponse(200, [...files.keys()].map((path) => ({ type: 'file', name: path.split('/').pop(), path, sha: 'b'.repeat(40) })));
+ }
+ if (method === 'GET' && u.pathname.startsWith('/repos/OpenVibers/demo/contents/')) {
+ const path = decodeURIComponent(u.pathname.slice('/repos/OpenVibers/demo/contents/'.length));
+ const source = files.get(path);
+ if (source == null) return jsonResponse(404, { message: 'Not Found' });
+ return new Response(source, { status: 200, headers: { 'Content-Type': 'text/plain' } });
+ }
+ if (method === 'POST' && u.pathname === '/repos/OpenVibers/demo/check-runs') {
+ const id = nextCheckRun++;
+ checkRuns.set(id, { ...(body || {}) });
+ return jsonResponse(201, { id, html_url: `https://github.test/OpenVibers/demo/runs/${id}` });
+ }
+ if (method === 'PATCH' && /^\/repos\/OpenVibers\/demo\/check-runs\/\d+$/.test(u.pathname)) {
+ const id = Number(u.pathname.split('/').pop());
+ checkRuns.set(id, { ...(checkRuns.get(id) || {}), ...(body || {}) });
+ return jsonResponse(200, { id, ...(checkRuns.get(id) || {}) });
+ }
+ return jsonResponse(404, { message: `stub has no route for ${method} ${u.pathname}` });
+ };
+ return { fetchImpl, calls, checkRuns, files, conversion, setFile: (path, source) => files.set(path, source) };
+}
+
+async function bootChecks(gh, extraEnv = {}) {
+ const t = await boot({
+ env: {
+ RUN_JOBS: 'on',
+ RUN_SECRETS_KEY: SECRETS_KEY,
+ RUN_CHECKS_PROJECT: PROJECT,
+ RUN_CHECKS_GITHUB_API: 'https://github.test',
+ RUN_CHECKS_GITHUB_WEB: 'https://github.test',
+ OWNER_USERNAME: 'goosely',
+ ...extraEnv,
+ },
+ appOpts: { fetchImpl: gh.fetchImpl },
+ });
+ return t;
+}
+
+async function seedApp(t, gh) {
+ const key = keyOf(SECRETS_KEY);
+ await t.db.maybe(
+ `INSERT INTO run_checks_app (id, app_id, slug, name, html_url, pem_enc, webhook_secret_enc, client_id, client_secret_enc, created_at, updated_at)
+ VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $9)`,
+ [gh.conversion.id, gh.conversion.slug, gh.conversion.name, gh.conversion.html_url,
+ seal(key, gh.conversion.pem, `app:${gh.conversion.id}:pem`),
+ seal(key, WEBHOOK_SECRET, `app:${gh.conversion.id}:webhook_secret`),
+ gh.conversion.client_id, seal(key, gh.conversion.client_secret, `app:${gh.conversion.id}:client_secret`), Date.now()]);
+}
+
+function userCookie(t, { username = 'goosely', subject = ids.newId('user') } = {}) {
+ const token = jwt.sign({ sub: String(Date.now()), subject_id: subject, username, display_name: username, role: 'user' },
+ t.network.privatePem, { algorithm: 'RS256', issuer: t.network.issuer, audience: 'openvibe.network', expiresIn: '1h' });
+ return `ov_token=${token}`;
+}
+
+const setCookies = (res) => (typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean));
+
+async function postWebhook(t, event, payload, { delivery = crypto.randomUUID(), secret = WEBHOOK_SECRET, signature = true } = {}) {
+ const raw = Buffer.from(JSON.stringify(payload));
+ const headers = { 'Content-Type': 'application/json', 'X-GitHub-Event': event, 'X-GitHub-Delivery': delivery };
+ if (signature) headers['X-Hub-Signature-256'] = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
+ const res = await fetch(`${t.base}/webhooks/github`, { method: 'POST', headers, body: raw });
+ return { status: res.status, text: await res.text(), headers: res.headers };
+}
+
+const repoOf = (full = 'OpenVibers/demo') => {
+ const [owner, name] = full.split('/');
+ return { full_name: full, name, owner: { login: owner, id: 7, type: 'Organization' }, default_branch: 'main', clone_url: `https://github.com/${full}.git`, private: false };
+};
+const installationOf = () => ({ id: 42, account: { login: 'OpenVibers', id: 7, type: 'Organization' }, suspended_at: null });
+const pushPayload = (over = {}) => ({ ref: 'refs/heads/main', after: SHA, repository: repoOf(), installation: installationOf(), ...over });
+
+const CI = `
+name: CI
+on:
+ push:
+ branches: [main]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Build
+ test:
+ needs: build
+ runs-on: ubuntu-latest
+ name: Test
+`;
+
+async function main() {
+ console.log('checks scheduler');
+
+ await check('planner: on branches, needs stages, matrix include/exclude, reusable sub-play', async () => {
+ assert.equal(eventMatches('push', { event: 'pull_request', branch: 'main' }), false);
+ assert.equal(eventMatches({ push: { branches: ['main'] } }, { event: 'push', branch: 'main' }), true);
+ assert.equal(eventMatches({ push: { branches: ['release/**'] } }, { event: 'push', branch: 'main' }), false);
+ const combos = matrixCombinations({ matrix: { node: [18, 20], os: ['ubuntu-latest'], include: [{ node: 22, experimental: true }, { os: 'macos-latest', node: 18 }], exclude: [{ node: 20 }] } });
+ assert.equal(combos.length, 3);
+ assert.deepEqual(combos.map((c) => c.node), [18, 22, 18]);
+ assert.deepEqual(combos[2], { os: 'macos-latest', node: 18 });
+
+ const workflows = [
+ { path: '.github/workflows/ci.yml', source: CI },
+ { path: '.github/workflows/pr.yml', source: 'on: pull_request\njobs:\n pr: {runs-on: ubuntu-latest}\n' },
+ ];
+ const planned = await planWorkflows({ workflows, event: 'push', branch: 'main' });
+ assert.equal(planned.errors.length, 0);
+ assert.equal(planned.workflows.length, 1);
+ const byKey = Object.fromEntries(planned.workflows[0].jobs.map((j) => [j.key, j]));
+ assert.equal(byKey.build.stage, 0);
+ assert.equal(byKey.test.stage, 1);
+ assert.deepEqual(byKey.test.needs, ['build']);
+
+ const reusable = [{ path: '.github/workflows/ci.yml', source: 'on: push\njobs:\n setup: {runs-on: ubuntu-latest}\n call:\n needs: setup\n uses: ./.github/workflows/reuse.yml\n after:\n needs: call\n' }];
+ const sub = await planWorkflows({
+ workflows: reusable, event: 'push', branch: 'main',
+ readWorkflow: async () => 'on: workflow_call\njobs:\n build: {runs-on: ubuntu-latest}\n test:\n needs: build\n',
+ });
+ const jobs = Object.fromEntries(sub.workflows[0].jobs.map((j) => [j.key, j]));
+ assert.equal(jobs.setup.stage, 0);
+ assert.equal(jobs['call/build'].stage, 1);
+ assert.deepEqual(jobs['call/build'].needs, ['setup']);
+ assert.equal(jobs['call/test'].stage, 2);
+ assert.equal(jobs['call/test'].display_name, 'call / test');
+ assert.deepEqual(jobs.after.needs.sort(), ['call/build', 'call/test']);
+ });
+
+ await check('owner gate on /admin/checks', async () => {
+ const gh = startGitHubStub();
+ const t = await bootChecks(gh);
+ try {
+ const anon = await fetch(`${t.base}/admin/checks`, { redirect: 'manual' });
+ assert.equal(anon.status, 302);
+ assert.match(anon.headers.get('location'), /^\/auth\/login/);
+ const other = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: userCookie(t, { username: 'not-the-owner' }) } });
+ assert.equal(other.status, 403);
+ const owner = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: userCookie(t) } });
+ assert.equal(owner.status, 200);
+ const bearer = await fetch(`${t.base}/admin/checks`, { headers: { Authorization: `Bearer ${t.network.signUser({ username: 'goosely' })}` } });
+ assert.equal(bearer.status, 200, 'the owner may also present a Network user token');
+ const html = await owner.text();
+ assert.match(html, /Create the GitHub App/);
+ assert.match(html, /method="post"/);
+ assert.match(html, /organizations\/OpenVibers\/settings\/apps\/new\?state=/);
+ const manifest = JSON.parse(html.match(/name="manifest" value="([^"]*)"/)[1].replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
+ assert.equal(manifest.name, 'OpenVibe Checks');
+ assert.deepEqual(manifest.default_permissions, { checks: 'write', statuses: 'write', contents: 'read', pull_requests: 'read', metadata: 'read', actions: 'read' });
+ assert.deepEqual(manifest.default_events, ['push', 'pull_request', 'check_suite', 'workflow_job']);
+ assert.equal(manifest.public, false);
+ assert.equal(manifest.hook_attributes.url, `${t.config.baseUrl}/webhooks/github`);
+ } finally { await t.close(); }
+ });
+
+ await check('manifest exchange: state checked, secrets sealed, raw PEM never in a row or the page', async () => {
+ const gh = startGitHubStub();
+ const t = await bootChecks(gh);
+ try {
+ const cookie = userCookie(t);
+ const page = await fetch(`${t.base}/admin/checks`, { headers: { Cookie: cookie } });
+ assert.equal(page.status, 200);
+ const html = await page.text();
+ const state = new URL(html.match(/action="([^"]+)"/)[1].replace(/&/g, '&')).searchParams.get('state');
+ const stateCookie = setCookies(page).find((c) => c.startsWith('ov_checks_state='));
+ assert.ok(stateCookie);
+ const created = await fetch(`${t.base}/admin/checks/created?code=code-ok&state=${encodeURIComponent(state)}`, {
+ headers: { Cookie: `${cookie}; ${stateCookie.split(';')[0]}` }, redirect: 'manual',
+ });
+ assert.equal(created.status, 303);
+ assert.equal(created.headers.get('location'), '/admin/checks?created=1');
+
+ const row = await t.db.maybe('SELECT * FROM run_checks_app WHERE id = 1');
+ assert.equal(Number(row.app_id), APP_ID);
+ assert.equal(row.slug, 'openvibe-checks');
+ assert.ok(row.pem_enc && !row.pem_enc.includes(gh.conversion.pem));
+ assert.ok(!JSON.stringify(row).includes(gh.conversion.pem));
+ assert.ok(!JSON.stringify(row).includes('test-client-secret-not-real'));
+ const key = keyOf(SECRETS_KEY);
+ assert.equal(open(key, row.pem_enc, `app:${APP_ID}:pem`), gh.conversion.pem);
+ assert.equal(open(key, row.webhook_secret_enc, `app:${APP_ID}:webhook_secret`), WEBHOOK_SECRET);
+ assert.equal(open(key, row.client_secret_enc, `app:${APP_ID}:client_secret`), 'test-client-secret-not-real');
+
+ const after = await fetch(`${t.base}/admin/checks?created=1`, { headers: { Cookie: cookie } });
+ const afterHtml = await after.text();
+ assert.match(afterHtml, /installed|created/);
+ assert.match(afterHtml, /Install OpenVibe Checks on OpenVibers/);
+ assert.ok(!afterHtml.includes(gh.conversion.pem));
+ assert.ok(!afterHtml.includes('test-client-secret-not-real'));
+ assert.ok(!afterHtml.includes(WEBHOOK_SECRET));
+ } finally { await t.close(); }
+ });
+
+ await check('webhook: signature good/bad/missing and delivery dedupe', async () => {
+ const gh = startGitHubStub();
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ const delivery = crypto.randomUUID();
+ const good = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery });
+ assert.equal(good.status, 204);
+ const again = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery });
+ assert.equal(again.status, 204);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_deliveries'), 1);
+ const bad = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery: crypto.randomUUID(), secret: 'wrong' });
+ assert.equal(bad.status, 401);
+ const missing = await postWebhook(t, 'ping', { zen: 'hello' }, { delivery: crypto.randomUUID(), signature: false });
+ assert.equal(missing.status, 401);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_deliveries'), 1);
+ } finally { await t.close(); }
+ });
+
+ await check('webhook: a repository outside OpenVibers is ignored (no read, no plan)', async () => {
+ const gh = startGitHubStub();
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ const payload = pushPayload({ repository: repoOf('SomeoneElse/demo') });
+ const res = await postWebhook(t, 'push', payload, { delivery: crypto.randomUUID() });
+ assert.equal(res.status, 204);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 0);
+ assert.equal(gh.calls.filter((c) => c.path.includes('/contents/')).length, 0);
+ } finally { await t.close(); }
+ });
+
+ await check('push plans the workflow, queues stage 0 and creates a queued check run per job', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ const res = await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ assert.equal(res.status, 204);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 1);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_jobs'), 2);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1);
+ const created = gh.calls.filter((c) => c.method === 'POST' && c.path === '/repos/OpenVibers/demo/check-runs');
+ assert.equal(created.length, 2);
+ assert.deepEqual(created.map((c) => c.body.name).sort(), ['openvibe / Build', 'openvibe / Test']);
+ assert.ok(created.every((c) => c.body.status === 'queued' && c.body.head_sha === SHA));
+ const tokenCalls = gh.calls.filter((c) => c.path === '/app/installations/42/access_tokens');
+ assert.equal(tokenCalls.length, 1, 'installation token is cached');
+ assert.ok(tokenCalls[0].authorization.startsWith('Bearer eyJ'));
+ assert.ok(gh.calls.filter((c) => c.path.includes('/contents/')).every((c) => c.authorization === 'Bearer test-installation-token-not-real'));
+ const tokenRow = await t.db.maybe('SELECT * FROM run_checks_tokens WHERE installation_id = 42');
+ assert.ok(tokenRow, 'the installation token is cached in the database');
+ assert.ok(!tokenRow.token_enc.includes('test-installation-token-not-real'));
+ assert.equal(open(keyOf(SECRETS_KEY), tokenRow.token_enc, 'installation:42'), 'test-installation-token-not-real');
+
+ const run = await t.db.maybe('SELECT * FROM run_jobs');
+ assert.equal(run.class, 'code');
+ assert.equal(run.job.artifact.name, 'openvibe-codes');
+ assert.equal(run.job.artifact.version, '1.0.0');
+ assert.equal(run.job.args.kind, 'check');
+ assert.equal(run.job.args.repo, 'https://github.com/OpenVibers/demo.git');
+ assert.equal(run.job.args.sha, SHA);
+ assert.equal(run.job.args.workflow, '.github/workflows/ci.yml');
+ assert.equal(run.job.args.job, 'build');
+ assert.equal(run.job.args.event, 'push');
+ assert.equal(run.job.args.ref, 'refs/heads/main');
+ assert.equal(run.job.net, 'public');
+ assert.deepEqual(run.job.requirements.trust, ['first-party', 'user-owned']);
+ assert.equal(run.project_id, PROJECT);
+ assert.equal(run.requester, 'service:checks');
+ const waiting = await t.db.maybe("SELECT * FROM run_checks_jobs WHERE state = 'waiting'");
+ assert.equal(waiting.display_name, 'Test');
+ assert.deepEqual(waiting.needs, ['build']);
+ } finally { await t.close(); }
+ });
+
+ await check('check runs: in_progress when the node starts, completed with the steps and details_url', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ assert.equal((await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() })).status, 204);
+ const build = await t.db.maybe("SELECT cj.id AS check_job_id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");
+ await t.db.maybe("UPDATE run_jobs SET state = 'running', started_ms = $2, node_id = 'dev_node_1' WHERE id = $1", [build.run_job_id, Date.now()]);
+ await t.checks.tick();
+ let check = gh.checkRuns.get(Number(build.check_run_id));
+ assert.equal(check.status, 'in_progress');
+ assert.ok(check.started_at);
+ assert.match(check.details_url, new RegExp(`/api/v1/jobs/${build.run_job_id}$`));
+
+ const finished = Date.now();
+ await t.db.maybe(
+ "UPDATE run_jobs SET state = 'succeeded', finished_ms = $2, wall_ms = 1200, result = $3::jsonb WHERE id = $1",
+ [build.run_job_id, finished, JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }], ms: 1200 })]);
+ await t.checks.tick();
+ check = gh.checkRuns.get(Number(build.check_run_id));
+ assert.equal(check.status, 'completed');
+ assert.equal(check.conclusion, 'success');
+ assert.match(check.output.summary, /ok/);
+ assert.match(check.details_url, new RegExp(`/api/v1/jobs/${build.run_job_id}$`));
+ const row = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [build.check_job_id]);
+ assert.equal(row.state, 'completed');
+ assert.equal(row.conclusion, 'success');
+ assert.ok(Number(row.queue_ms) >= 0 && Number(row.run_ms) >= 0);
+ const next = await t.db.maybe("SELECT cj.*, rj.id AS run_job_id FROM run_checks_jobs cj LEFT JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Test'");
+ assert.ok(next.run_job_id, 'the later stage is queued when its need succeeds');
+ const shadow = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE job_name = 'Build'");
+ assert.equal(shadow.node_conclusion, 'success');
+ } finally { await t.close(); }
+ });
+
+ await check('shadow: workflow_job completed records the Actions conclusion; agreement is per repo and owner-only', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ const build = await t.db.maybe("SELECT cj.id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");
+ const started = Date.now() - 5000;
+ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', started_ms = $2, finished_ms = $3, wall_ms = 4000, result = $4::jsonb WHERE id = $1",
+ [build.run_job_id, started, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [{ name: 'ok', conclusion: 'success', ms: 10 }] })]);
+ await t.checks.tick();
+
+ const actions = await postWebhook(t, 'workflow_job', {
+ action: 'completed', repository: repoOf(), installation: installationOf(),
+ workflow_job: { id: 9001, name: 'Build', head_sha: SHA, conclusion: 'success', workflow_name: 'CI', started_at: new Date(started).toISOString(), completed_at: new Date().toISOString() },
+ }, { delivery: crypto.randomUUID() });
+ assert.equal(actions.status, 204);
+ const pair = await t.db.maybe("SELECT * FROM run_checks_shadow WHERE repo = 'OpenVibers/demo' AND sha = $1 AND job_name = 'Build'", [SHA]);
+ assert.equal(pair.actions_conclusion, 'success');
+ assert.equal(pair.node_conclusion, 'success');
+
+ const owner = userCookie(t);
+ const anon = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`);
+ assert.equal(anon.status, 401);
+ const other = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`, { headers: { Cookie: userCookie(t, { username: 'ada' }) } });
+ assert.equal(other.status, 403);
+ const agreement = await fetch(`${t.base}/api/v1/checks/agreement?repo=OpenVibers/demo`, { headers: { Cookie: owner } });
+ assert.equal(agreement.status, 200);
+ const body = await agreement.json();
+ assert.equal(body.repos.length, 1);
+ assert.equal(body.repos[0].repo, 'OpenVibers/demo');
+ assert.equal(body.repos[0].paired, 1);
+ assert.equal(body.repos[0].agreement, 1);
+ assert.ok(body.repos[0].run_ms >= 0);
+ } finally { await t.close(); }
+ });
+
+ await check('pull_request opened plans at the head SHA; closed and a fork head do not', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/pr.yml', 'name: PR\non: pull_request\njobs:\n pr:\n runs-on: ubuntu-latest\n name: PR check\n');
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ const pr = (over = {}) => ({
+ action: 'opened', number: 7, repository: repoOf(), installation: installationOf(),
+ pull_request: { number: 7, head: { sha: SHA, ref: 'feature', repo: repoOf() }, base: { ref: 'main' } },
+ ...over,
+ });
+ assert.equal((await postWebhook(t, 'pull_request', pr(), { delivery: crypto.randomUUID() })).status, 204);
+ const plan = await t.db.maybe('SELECT * FROM run_checks_plans');
+ assert.equal(plan.event, 'pull_request');
+ assert.equal(plan.ref, 'refs/pull/7/merge');
+ assert.equal(plan.branch, 'main');
+ const run = await t.db.maybe('SELECT * FROM run_jobs');
+ assert.equal(run.job.args.event, 'pull_request');
+ assert.equal(run.job.args.ref, 'refs/pull/7/merge');
+
+ const before = await t.db.value('SELECT COUNT(*) FROM run_checks_plans');
+ const reads = gh.calls.length;
+ assert.equal((await postWebhook(t, 'pull_request', pr({ action: 'closed' }), { delivery: crypto.randomUUID() })).status, 204);
+ const fork = pr({ pull_request: { number: 8, head: { sha: SHA, ref: 'feature', repo: repoOf('Fork/demo') }, base: { ref: 'main' } } });
+ assert.equal((await postWebhook(t, 'pull_request', fork, { delivery: crypto.randomUUID() })).status, 204);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), before);
+ assert.equal(gh.calls.length, reads, 'closed and fork deliveries make no GitHub call');
+ } finally { await t.close(); }
+ });
+
+ await check('a failed need skips the later job and its check run', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ const build = await t.db.maybe("SELECT cj.id AS check_job_id, cj.check_run_id, rj.id AS run_job_id FROM run_checks_jobs cj JOIN run_jobs rj ON rj.id = cj.run_job_id WHERE cj.display_name = 'Build'");
+ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'exited', exit_code = 1, result = $3::jsonb WHERE id = $1",
+ [build.run_job_id, Date.now(), JSON.stringify({ ok: false, conclusion: 'failure', steps: [{ name: 'test', conclusion: 'failure', ms: 5 }] })]);
+ await t.checks.tick();
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1, 'the later stage is not queued');
+ const test = await t.db.maybe("SELECT * FROM run_checks_jobs WHERE display_name = 'Test'");
+ assert.equal(test.state, 'completed');
+ assert.equal(test.conclusion, 'skipped');
+ const checkRun = gh.checkRuns.get(Number(test.check_run_id));
+ assert.equal(checkRun.status, 'completed');
+ assert.equal(checkRun.conclusion, 'skipped');
+ const plan = await t.db.maybe('SELECT * FROM run_checks_plans');
+ assert.equal(plan.state, 'failed');
+ } finally { await t.close(); }
+ });
+
+ await check('matrix max-parallel limits the queued combinations', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/matrix.yml', 'name: Matrix\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n max-parallel: 1\n matrix:\n node: [18, 20]\n');
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 1);
+ const waiting = await t.db.many("SELECT * FROM run_checks_jobs WHERE state = 'waiting'");
+ assert.equal(waiting.length, 1);
+ const first = await t.db.maybe('SELECT * FROM run_jobs');
+ await t.db.maybe("UPDATE run_jobs SET state = 'succeeded', finished_ms = $2, wall_ms = 100, result = $3::jsonb WHERE id = $1",
+ [first.id, Date.now(), JSON.stringify({ ok: true, conclusion: 'success', steps: [] })]);
+ await t.checks.tick();
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2);
+ const second = await t.db.maybe('SELECT * FROM run_jobs WHERE id <> $1', [first.id]);
+ assert.deepEqual(second.job.args.matrix, { node: 20 });
+ } finally { await t.close(); }
+ });
+
+ await check('RUN_JOBS=off: no outbound call and no plan', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', CI);
+ const t = await bootChecks(gh, { RUN_JOBS: 'off' });
+ try {
+ await seedApp(t, gh);
+ assert.equal(t.checks.start(), false, 'the tick does not start with the background jobs off');
+ const res = await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ assert.equal(res.status, 204);
+ assert.equal(gh.calls.length, 0);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_checks_plans'), 0);
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 0);
+ } finally { await t.close(); }
+ });
+
+ await check('a lost node is retried once elsewhere, then the check fails', async () => {
+ const gh = startGitHubStub();
+ gh.setFile('.github/workflows/ci.yml', 'name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n name: Build\n');
+ const t = await bootChecks(gh);
+ try {
+ await seedApp(t, gh);
+ await postWebhook(t, 'push', pushPayload(), { delivery: crypto.randomUUID() });
+ const first = await t.db.maybe('SELECT * FROM run_jobs');
+ const job = await t.db.maybe('SELECT * FROM run_checks_jobs');
+ assert.equal(job.attempts, 1);
+ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'failed' WHERE id = $1", [first.id, Date.now()]);
+ await t.checks.tick();
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2);
+ const second = await t.db.maybe("SELECT * FROM run_jobs WHERE id <> $1", [first.id]);
+ const retried = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [job.id]);
+ assert.equal(retried.attempts, 2);
+ assert.equal(retried.state, 'queued');
+ assert.equal(retried.check_state, 'queued');
+ assert.equal(retried.check_run_id, job.check_run_id);
+ assert.notEqual(second.id, first.id);
+
+ await t.db.maybe("UPDATE run_jobs SET state = 'failed', finished_ms = $2, exit_reason = 'failed' WHERE id = $1", [second.id, Date.now()]);
+ await t.checks.tick();
+ assert.equal(await t.db.value('SELECT COUNT(*) FROM run_jobs'), 2, 'no third attempt');
+ const doneRow = await t.db.maybe('SELECT * FROM run_checks_jobs WHERE id = $1', [job.id]);
+ assert.equal(doneRow.state, 'completed');
+ assert.equal(doneRow.conclusion, 'failure');
+ const checkRun = gh.checkRuns.get(Number(job.check_run_id));
+ assert.equal(checkRun.status, 'completed');
+ assert.equal(checkRun.conclusion, 'failure');
+ } finally { await t.close(); }
+ });
+
+ done();
+}
+
+main().catch((e) => { console.error(e.stack || e); process.exit(1); });
@@ -285,7 +285,7 @@ async function boot(opts = {}) {
@@ -285,7 +285,7 @@ async function boot(opts = {}) {
.filter((e) => (!type || e.event_type === type) && (!jobId || e.payload.job_id === jobId));
.filter((e) => (!type || e.event_type === type) && (!jobId || e.payload.job_id === jobId));
return {
return {
- app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch,
+ app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch, checks: app.locals.checks,
// What the stub registry publishes for placement (server/dispatch/placement.js).
// What the stub registry publishes for placement (server/dispatch/placement.js).
setOffers: (list) => network.setOffers(list),
setOffers: (list) => network.setOffers(list),
outboxRows, streamState: (jobId) => stream.ringOf(jobId),
outboxRows, streamState: (jobId) => stream.ringOf(jobId),