Mapa completo del subsistema de Super Skills (workflows reutilizables promovidos desde Nova), cómo se registran e invocan hoy, los 5 gaps de readiness verificados con evidencia archivo:línea, y el checklist para garantizar que estén operativos antes de que arranque cualquier workflow.
Un Super Skill es la copia 1:1 del draft de un plan_draft compuesto por Nova (shape {template, constraints}), promovida con nombre propio a un artefacto reutilizable. No es una Lambda ni un microservicio: es una fila JSONB en Postgres que el motor compila y ejecuta bajo demanda.
superskills del substrate (Postgres :5433), migración 0007_superskills.sql. Ciclo de vida: insert, luego archive (soft delete). El contenido nunca se actualiza.POST /api/workspaces/:id/superskills con {draft_id, name}. Se valida contra OPERATION_CATALOG antes del INSERT.{{intent.constraints.X}} extraídas del template (constraintKeysOf), menos la denylist gate_timeout_ms. Overrides fuera de esas claves se silencian.POST .../superskills/:skillId/launch fusiona overrides y delega en launchCompiledPlan: crea Intent + Plan + Trace y emite plan.compiled directo al motor, saltando intent.declared a propósito.match_custom contra el registro, nunca contra el texto del LLM.Cada fila incluye entrypoint exacto, inputs, outputs y dependencias. La tabla scrollea horizontal.
| Componente | Entrypoint | Inputs | Outputs | Dependencias |
|---|---|---|---|---|
Tabla superskillsdb/substrate/migrations/0007_superskills.sql:23-44 | CREATE TABLE superskills | DDL | id, workspace_id, name, plan JSONB, est_cost_usd, source_draft_id, archived_at; único parcial uq_superskills_source_active | Postgres substrate :5433. Sin FK a plan_drafts (deliberado) |
| Store substrateapps/api/src/substrate/superskills.ts:26-147 | insertSuperskill · listSuperskills · getSuperskill · archiveSuperskill · constraintKeysOf | plan copiado 1:1 del draft; workspace_id, name | SuperskillRow; constraint keys menos denylist | validatePlanAgainstCatalog antes del INSERT; list/get filtran archived_at IS NULL |
| Ruta promoteapps/api/src/routes/superskills.ts:57-129 | POST /api/workspaces/:id/superskills | {draft_id, name 3..80, description?} + Bearer | 201 con skill + constraint_keys/values; 409 already_promoted / draft_not_promotable / invalid_plan | getPlanDraft, insertSuperskill. No muta el draft, no emite eventos, sin rate limit |
| Ruta listapps/api/src/routes/superskills.ts:137-153 | GET /api/workspaces/:id/superskills | params uuid + Bearer | 200 {skills:[...]} con constraint_keys y values pre-llenados | listSuperskills (solo activos, created_at DESC) |
| Ruta launchapps/api/src/routes/superskills.ts:171-215 | POST .../superskills/:skillId/launch | {constraints?, declared_by?}; claves fuera del schema se silencian (l.189-194) | 201 {launched, intent_id, plan_id, trace_id}; 404 / 409 / 502 | getSuperskill + launchCompiledPlan; rate limit 10/min por workspace (index.ts:144-150) |
launchCompiledPlanapps/api/src/substrate/launch-plan.ts:98-144 | función compartida (superskills + adhoc) | template con id skill-<skillId>; constraints fusionadas | {intent_id, plan_id, trace_id}; throw invalid_plan | Re-valida contra catálogo; fuerza human_gate con fallback 'fail'; emite plan.compiled, nunca intent.declared |
| nova-compose (puro)apps/api/src/substrate/nova-compose.ts | interpretNovaText · buildComposeSystemPrompt | texto crudo del LLM + customSkills + forgedOps | NovaOutcome: match / match_custom / plan (máx 16 steps, 4 LLM) / cannot / invalid | OPERATION_CATALOG, EVALUATOR_CATALOG, zod. Sin I/O |
| Ruta composeapps/api/src/routes/compose.ts:68-296 | POST /api/workspaces/:id/compose (+launch, +discard) | {request 10..1000, attachments? ≤3}; timeout LLM 80s + 1 reintento | fila plan_drafts + 201 {draft_id, estimated_cost_usd, steps, agents} | generateLLMText; reinyecta superskills cap 20 fail-soft |
| Adapter LLM + ModelPlaneapps/api/src/inngest/llm.ts:215-383 | generateLLMText({modelClass:'compose'}) | system prompt ~10KB + user prompt | {text, usage, provider, reportedCostUsd} | Default claude-cli con sesión OAuth (borra ANTHROPIC_API_KEY del child); fallback opt-in LLM_API_FALLBACK |
Store plan_draftsapps/api/src/substrate/plan-drafts.ts:24-70 | insertPlanDraft · getPlanDraft · flipPlanDraftStatus | draft jsonb, status, first_pass | estados proposed / launched / discarded / rejected / matched; flips optimistas | Postgres :5433 vía substrate/db.ts |
| Motor Inngestapps/api/src/inngest/functions/index.ts (10 funciones) | trigger plan.compiled; serve en /api/inngest | evento plan.compiled | ejecución topológica con retry + timeout; gates con waitForEvent; trace.completed al cierre | Executor self-hosted Docker :8288, --poll-interval 5, sdk-url host.docker.internal:4000 |
| Proxies BFF SvelteKitapps/web/src/routes/api/substrate/superskills/* | GET/POST /api/substrate/superskills · POST .../launch | sesión (gate doble locals.user + accessAuthorized) | GET fail-soft 200; launch devuelve traceId/intentId/planId | Consumidores: NovaModal, NovaOfficeDock, SkillLaunchModal |
| Cliente server-sideapps/web/src/lib/server/substrate.ts:887-1032 | fetchSuperskills · promoteSuperskill · launchSuperskill | env SUBSTRATE_API_URL/TOKEN/WORKSPACE_ID | fetch fail-soft a []; timeouts 2.5s / 8s / 10s / 88s | Bearer en cada llamada; el browser nunca ve el token |
| SSR + helpers UIapps/web/src/routes/workflow-library/+page.server.ts:10-20 | load · defaultSkillName · matchInputReady | locals.accessAuthorized; input del usuario | {canLaunch, mySkills} por SSR; nombre default ≤60 chars | Catálogo estático LIVE_WORKFLOWS + skills del workspace |
/workflow-library (SSR) o desde Nova; el browser hace POST /api/substrate/superskills/launch.Authorization: Bearer SUBSTRATE_API_TOKEN, timeout 10s.skill.plan.constraints con los overrides permitidos y silencia el resto.launchCompiledPlan re-valida el template como skill-<skillId>, fuerza el contrato de human_gate (fallback 'fail', gate terminal) y borra gate_timeout_ms.{{intent.constraints.X}}) y Trace en queued.plan.compiled y la ruta responde 201 síncrono con los tres ids; la escena de la UI reconcilia por trace_id.substrate-execute-plan: steps con retry + timeout, dos fases en step_executions, gates suspendidos con waitForEvent.completeTrace, intent a succeeded/failed, evento trace.completed, y notify despacha (hoy: solo a operaciones).Las 10 funciones viven en FUNCTIONS (functions/index.ts) y se sirven en /api/inngest, fuera del bearer. El executor las descubre por polling cada 5 segundos (--poll-interval 5 en el docker-compose), no por push: el PUT /api/inngest existe pero el auto-deploy no lo ejecuta. El guard de INNGEST_SERVE_HOST es fatal en prod pero valida topología estática, no registro real. La única prueba del path completo es el canary del cron slo-alert, cada 10 minutos.
El descubrimiento es una consulta a la DB: GET .../superskills lista activos con sus constraint_keys. La UI los recibe por SSR (fail-soft a lista vacía) y cada compose los reinyecta al prompt de Nova (cap 20): si Nova matchea custom:<uuid>, el server valida el uuid contra el registro, nunca confía en el texto del LLM.
Launch: 10/min por workspace. Intents clásicos: 10/min global. Promote y list sin rate limit propio. Todo /api/workspaces/* detrás del bearer compartido.
auto-deploy-api.sh:49-57 gatea solo con curl /health 200 y actualiza el marker; jamás hace PUT a /api/inngest ni consulta al executor. Mitigado por el polling de 5s del executor.PUT /api/inngest + verificar contra la API del executor que las 10 funciones quedaron registradas antes de actualizar el marker.routes/health.ts:9-39: checks = SELECT 1 + tracing. Ningún fetch a INNGEST_BASE_URL, ningún to_regclass de superskills. Agravante: migraciones repartidas en dos directorios; un bootstrap parcial es invisible.GET /ready compuesto: DB + schema + Inngest alcanzable + conteo de funciones registradas. Ese endpoint gatea el deploy.env.ts: INNGEST_EVENT_KEY (l.8), INNGEST_SIGNING_KEY (l.9), ANTHROPIC_API_KEY (l.24), SUBSTRATE_API_TOKEN (l.32) y NOTIFY_EMAIL_OPS (l.117) son todos optional; solo SUBSTRATE_DB_URL es obligatoria. Los guards fatales cubren solo serve-host y tracing.inngest.send, y sin NOTIFY_EMAIL_OPS los trace_failed se descartan en silencio (viola la regla solo-ops).assertProdEnvComplete() junto a los guards existentes: Inngest keys + token + email ops + al menos un path LLM viable.launch-plan.ts:118-141: Intent 'running', plan y trace 'queued' se crean ANTES del send; si el send falla no hay rollback y el reaper no los caza (solo caza step_executions). Si el executor vive pero la función no está registrada, el send acepta el evento y la ruta responde 201.engine_not_ready 503 en el endpoint de launch.agent-squad-api.service:3-4 ordena contra el daemon Docker (After=docker.service), no contra los contenedores substrate-postgres (:5433) ni substrate-inngest (:8288). La conexión Postgres es lazy y el boot no la prueba.ExecStartPre con espera activa de pg_isready :5433 y TCP :8288, más un SELECT 1 fail-fast antes del banner de listening.12 pasos ordenados. Marca los completados (se guardan en este navegador). Filtra por fase.
assertProdEnvComplete() junto a los guards de index.ts:64-65, exigiendo INNGEST_EVENT_KEY + INNGEST_SIGNING_KEY, SUBSTRATE_API_TOKEN, NOTIFY_EMAIL_OPS y un path LLM viable.Cierra el gap (c)SELECT to_regclass(...) para superskills, plan_drafts, intents, plans, traces, step_executions; fatal si falta alguna.Cubre los dos directorios de migraciones, gap (b)SELECT 1 real antes del banner de listening (hoy la conexión es lazy).Complemento del gap (e)ExecStartPre con espera activa de pg_isready :5433 y TCP :8288, acotado por TimeoutStartSec.Cierra el gap (e)curl -X PUT http://127.0.0.1:4000/api/inngest en auto-deploy-api.sh, sin depender solo del polling de 5s.Primera mitad del gap (a)FUNCTIONS figuran; si no coincide, no actualizar el marker.Segunda mitad del gap (a)GET /ready con DB + schema + Inngest alcanzable + conteo de funciones + env crítico, separado del /health barato.Cierra el gap (b)/ready, no con /health.Conecta (a) y (b)inngest.send en launch-plan.ts que marca intent y trace como failed si el send revienta.Primera mitad del gap (d)substrate-execute-plan está registrada; si no, responder 503 engine_not_ready en vez del 201 fantasma.Segunda mitad del gap (d)Rutas y nombres reales del repo. El endpoint exacto de la API del executor Inngest self-hosted queda por confirmar contra la versión desplegada.
// apps/api/src/routes/ready.ts (montar en index.ts junto a healthRoute) import { Hono } from 'hono'; import { sql } from '../substrate/db'; import { env } from '../env'; import { FUNCTIONS } from '../inngest/functions'; // las 10 funciones const REQUIRED_TABLES = ['superskills', 'plan_drafts', 'intents', 'plans', 'traces', 'step_executions']; export const readyRoute = new Hono().get('/ready', async (c) => { const checks: Record<string, boolean | string> = {}; // 1. DB viva try { await sql`SELECT 1 AS ok`; checks.substrate_db = true; } catch { checks.substrate_db = false; } // 2. Schema aplicado (cubre los DOS directorios de migraciones) try { const rows = await sql` SELECT unnest(${REQUIRED_TABLES}::text[]) AS t, to_regclass('public.' || unnest(${REQUIRED_TABLES}::text[])) IS NOT NULL AS present`; const missing = rows.filter(r => !r.present).map(r => r.t); checks.schema = missing.length === 0 ? true : `missing: ${missing.join(',')}`; } catch { checks.schema = false; } // 3. Executor Inngest alcanzable + funciones registradas try { const base = env.INNGEST_BASE_URL; // http://127.0.0.1:8288 const ping = await fetch(base, { signal: AbortSignal.timeout(2000) }); checks.inngest_reachable = ping.ok; // endpoint por confirmar segun version del executor (p.ej. /v1/apps) const apps = await fetch(`${base}/v1/apps`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()); const registered = apps?.data?.find((a) => a.name?.includes('substrate'))?.functions_count ?? 0; checks.inngest_functions = registered >= FUNCTIONS.length ? true : `registered ${registered}/${FUNCTIONS.length}`; } catch { checks.inngest_reachable = false; } // 4. Env critico (espejo de assertProdEnvComplete) checks.env = Boolean(env.INNGEST_EVENT_KEY && env.SUBSTRATE_API_TOKEN && env.NOTIFY_EMAIL_OPS) || 'missing critical env'; const ready = Object.values(checks).every(v => v === true); return c.json({ ready, checks }, ready ? 200 : 503); });
# /etc/systemd/system/agent-squad-api.service.d/robustness.conf (extender el drop-in existente) [Service] # Espera activa a las dependencias reales, no solo al daemon Docker (gap e) ExecStartPre=/bin/sh -c 'until pg_isready -q -h 127.0.0.1 -p 5433; do sleep 2; done' ExecStartPre=/bin/sh -c 'until (echo > /dev/tcp/127.0.0.1/8288) 2>/dev/null; do sleep 2; done' TimeoutStartSec=180 # substrate-infra/scripts/auto-deploy-api.sh (reemplaza el bloque de las lineas 49-57) systemctl restart agent-squad-api.service # 1. Gate de readiness compuesto (NO el /health barato) for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4000/ready) && [ "$code" = "200" ] && break sleep 2 done [ "$code" = "200" ] || { echo "ERROR: /ready=$code, marker NO actualizado" >&2; exit 1; } # 2. Forzar re-registro en el executor (no depender del polling de 5s) curl -sf -X PUT http://127.0.0.1:4000/api/inngest > /dev/null \ || { echo "ERROR: PUT /api/inngest fallo" >&2; exit 1; } # 3. Verificar registro real de las 10 funciones (endpoint por confirmar) fns=$(curl -sf http://127.0.0.1:8288/v1/apps | jq '[.data[] | select(.name|test("substrate")) | .functions_count] | add // 0') [ "$fns" -ge 10 ] || { echo "ERROR: solo $fns/10 funciones registradas" >&2; exit 1; } # 4. Smoke: disparar el canary ya, sin esperar el cron de 10 min (ruta por confirmar) curl -sf -X POST http://127.0.0.1:4000/api/dev/canary-fire > /dev/null || true # 5. Solo ahora, actualizar el marker git -C "$REPO" rev-parse HEAD > "$MARKER"
// apps/api/src/substrate/engine-ready.ts (guard con cache corto para no penalizar cada launch) let cache = { ok: false, at: 0 }; const TTL_MS = 15_000; export async function assertExecutePlanRegistered(): Promise<void> { if (Date.now() - cache.at < TTL_MS && cache.ok) return; try { // endpoint por confirmar segun version del executor self-hosted const res = await fetch(`${env.INNGEST_BASE_URL}/v1/apps`, { signal: AbortSignal.timeout(1500) }); const apps = await res.json(); const fns = apps?.data?.flatMap((a) => a.functions?.map((f) => f.id) ?? []) ?? []; cache = { ok: fns.some((id) => id.includes('substrate-execute-plan')), at: Date.now() }; } catch { cache = { ok: false, at: Date.now() }; } if (!cache.ok) throw new Error('engine_not_ready'); } // apps/api/src/routes/superskills.ts (dentro del handler de launch, ANTES de crear nada) try { await assertExecutePlanRegistered(); } catch { return c.json({ error: 'engine_not_ready' }, 503); // en vez del 201 fantasma del gap (d) } // ... getSuperskill + merge de overrides + launchCompiledPlan como hoy ... // Y en launch-plan.ts, la compensacion (mitad 1 del gap d): try { await inngest.send({ name: 'plan.compiled', data: { intent_id, plan_id, template_id, workspace_id } }); } catch (err) { await updateIntentStatus(intent.id, 'failed'); await completeTrace(trace.id, { verdict: 'failed', reason: 'event_send_failed' }); // firma real por confirmar throw err; // la ruta lo mapea a 502 launch_failed como hoy, pero ya sin huerfanos 'running' }