Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
RECERTIA_MODEL_PROVIDER=openai
RECERTIA_MODEL_ID=deepseek/deepseek-v4-flash-0731
OPENAI_API_KEY=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
RECERTIA_OPENAI_BASE_URL=https://openrouter.ai/api/v1/chat/completions
RECERTIA_OPENAI_HTTP_REFERER=https://github.com/quantrobs/fandea
RECERTIA_OPENAI_TITLE=Recertia
RECERTIA_OPENAI_EXTRA_BODY={"temperature":0.2,"max_tokens":1024}
RECERTIA_MODEL_PRICE_OPENAI_DEEPSEEK_DEEPSEEK_V4_FLASH_0731_IN=0.09
RECERTIA_MODEL_PRICE_OPENAI_DEEPSEEK_DEEPSEEK_V4_FLASH_0731_OUT=0.18
RECERTIA_EXECUTION_BACKEND=local
RECERTIA_API_ALLOW_LOCAL_EXEC=1
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ Details: [`docs/architecture/go-live.md`](docs/architecture/go-live.md),
| [`docs/architecture/production-readiness.md`](docs/architecture/production-readiness.md) | Phase-4 multi-tenant readiness gate checklist |
| [`docs/architecture/product-console.md`](docs/architecture/product-console.md) | Product console (Pilot / Tower) architecture |
| [`docs/specifications/product-console.md`](docs/specifications/product-console.md) | Console HTTP, SSE events, UX, and conformance tests |
| [`docs/specifications/registered-workspaces.md`](docs/specifications/registered-workspaces.md) | Registered host workspaces (Windows); Pilot workdir bind |
| [`docs/implementation-plan-console.md`](docs/implementation-plan-console.md) | Console milestones C0–C5 |
| [`docs/implementation-plan-registered-workspaces.md`](docs/implementation-plan-registered-workspaces.md) | Registered workspaces milestones RW0–RW2 |
| [`docs/adr/0012-product-console-surfaces.md`](docs/adr/0012-product-console-surfaces.md) | ADR: console as control plane over headless Recertia |
| [`docs/specifications/`](docs/specifications/core-entities.md) | Data model, graph state, node contracts, retrieval/validation/distillation specs, failure taxonomy, capacity and retirement, concurrency and merge contracts, HTTP/CLI surface, metrics |
| [`docs/specifications/goal-objects.md`](docs/specifications/goal-objects.md) | Goal as primary input (Variant B) |
Expand Down
145 changes: 135 additions & 10 deletions console/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,61 @@ const state = {
function apiKey() { return $("#apiKey").value.trim(); }
function tenantHeader() { return $("#tenantHeader").value.trim(); }

function looksAbsolutePath(s) {
const v = (s || "").trim();
if (!v) return false;
if (v.startsWith("/") || v.startsWith("\\")) return true;
if (/^[A-Za-z]:[\\/]/.test(v)) return true;
if (v.startsWith("//") || v.startsWith("\\\\")) return true;
return false;
}

function selectedWorkspaceId() {
return ($("#workspaceSelect") && $("#workspaceSelect").value.trim()) || "";
}

function workspaceSubpath() {
return ($("#workspaceSubpath") && $("#workspaceSubpath").value.trim()) || "";
}

function buildRunSubmitBody(goal, mode) {
const body = {
goal,
task_class: goal.task_class || "repo-chore",
mode,
budget: { max_attempts: 2 },
};
const ws = selectedWorkspaceId();
if (ws) {
body.workspace_id = ws;
body.workdir = workspaceSubpath();
}
return body;
}

async function loadWorkspaces() {
const sel = $("#workspaceSelect");
if (!sel) return;
const current = sel.value;
try {
const data = await api("/v1/workspaces");
const enabled = (data.workspaces || []).filter((w) => w.enabled !== false);
sel.innerHTML = `<option value="">sandbox — new empty workdir</option>`;
for (const w of enabled) {
const opt = document.createElement("option");
opt.value = w.workspace_id;
opt.textContent = `${w.display_name} (${w.workspace_id}) — ${w.host_root}`;
sel.appendChild(opt);
}
if (current && [...sel.options].some((o) => o.value === current)) sel.value = current;
if ($("#workspacesOut")) {
$("#workspacesOut").textContent = JSON.stringify(data.workspaces || [], null, 2);
}
} catch (e) {
if ($("#workspacesOut")) $("#workspacesOut").textContent = String(e);
}
}

async function api(path, opts = {}) {
const headers = Object.assign({ "content-type": "application/json" }, opts.headers || {});
if (apiKey()) headers["X-API-Key"] = apiKey();
Expand All @@ -32,6 +87,9 @@ function showView(name) {
document.querySelectorAll(".nav button[data-view]").forEach((el) => el.classList.remove("active"));
$(`#view-${name}`).classList.add("active");
document.querySelector(`.nav button[data-view="${name}"]`).classList.add("active");
if (name === "pilot" || name === "auth" || name === "programs") {
loadWorkspaces().catch(() => {});
}
}

document.querySelectorAll(".nav button[data-view]").forEach((btn) => {
Expand All @@ -47,7 +105,10 @@ function setPilotMode(mode) {
}

document.querySelectorAll("[data-pilot-mode]").forEach((btn) => {
btn.addEventListener("click", () => setPilotMode(btn.dataset.pilotMode));
btn.addEventListener("click", () => {
setPilotMode(btn.dataset.pilotMode);
if (btn.dataset.pilotMode === "run") loadWorkspaces().catch(() => {});
});
});

function desiredValue(prefill) {
Expand Down Expand Up @@ -322,9 +383,16 @@ $("#previewGoal").onclick = async () => {

$("#submitRun").onclick = async () => {
try {
const sub = workspaceSubpath();
if (looksAbsolutePath(sub)) {
throw new Error("Subpath must be relative to the registered workspace (absolute paths rejected)");
}
if (!selectedWorkspaceId() && looksAbsolutePath(sub)) {
throw new Error("Sandbox workdir must be relative");
}
const goal = buildGoal();
const mode = $("#runMode").value;
const body = { goal, task_class: goal.task_class, mode, budget: { max_attempts: 2 } };
const body = buildRunSubmitBody(goal, mode);
const res = await fetch("/v1/runs", {
method: "POST",
headers: {
Expand Down Expand Up @@ -529,8 +597,32 @@ $("#doSwitch").onclick = async () => {
$("#authOut").textContent = JSON.stringify(d, null, 2);
};

$("#refreshWorkspaces").onclick = () => loadWorkspaces().catch((e) => {
$("#workspacesOut").textContent = String(e);
});
$("#registerWorkspace").onclick = async () => {
try {
const host = $("#wsHostRoot").value.trim();
if (!host) throw new Error("host_root required");
const d = await api("/v1/workspaces", {
method: "POST",
body: JSON.stringify({
workspace_id: $("#wsId").value.trim(),
display_name: $("#wsName").value.trim() || $("#wsId").value.trim(),
host_root: host,
notes: $("#wsNotes").value.trim() || null,
}),
});
$("#workspacesOut").textContent = JSON.stringify(d, null, 2);
await loadWorkspaces();
} catch (e) {
$("#workspacesOut").textContent = String(e);
}
};

setPilotMode("compose");
loadTemplates();
loadWorkspaces().catch(() => {});

/* ----- Migration programs (GP0 board) ----- */
state.programId = null;
Expand Down Expand Up @@ -575,11 +667,31 @@ function renderProgramSteps(prog, warnings) {
<button type="button" class="secondary" data-act="preview">Preview</button>
<button type="button" class="secondary" data-act="envelope">Run envelope</button>
<button type="button" class="primary" data-act="submit-bind">Submit + bind</button>
<input data-workdir placeholder="workdir (relative)" value="" />
<select data-workspace><option value="">sandbox / relative</option></select>
<input data-workdir placeholder="subpath or relative workdir" value="" />
</div>`;
const wsSel = el.querySelector("[data-workspace]");
const pilotSel = $("#workspaceSelect");
if (pilotSel) {
for (const opt of [...pilotSel.options]) {
if (!opt.value) continue;
const clone = opt.cloneNode(true);
wsSel.appendChild(clone);
}
}
const readBind = () => ({
workdir: el.querySelector("[data-workdir]").value,
workspace_id: wsSel.value || null,
});
el.querySelector('[data-act="preview"]').onclick = () => previewProgramStep(step.step_id);
el.querySelector('[data-act="envelope"]').onclick = () => envelopeProgramStep(step.step_id, el.querySelector("[data-workdir]").value);
el.querySelector('[data-act="submit-bind"]').onclick = () => submitBindProgramStep(step.step_id, el.querySelector("[data-workdir]").value);
el.querySelector('[data-act="envelope"]').onclick = () => {
const b = readBind();
envelopeProgramStep(step.step_id, b.workdir, b.workspace_id);
};
el.querySelector('[data-act="submit-bind"]').onclick = () => {
const b = readBind();
submitBindProgramStep(step.step_id, b.workdir, b.workspace_id);
};
box.appendChild(el);
});
if (warnings && warnings.length) {
Expand All @@ -600,23 +712,35 @@ async function previewProgramStep(stepId) {
}
}

async function envelopeProgramStep(stepId, workdir) {
async function envelopeProgramStep(stepId, workdir, workspaceId) {
try {
const out = await api(`/v1/programs/${state.programId}/steps/${stepId}/run`, {
method: "POST",
body: JSON.stringify({ plan_only: true, workdir: workdir || null }),
body: JSON.stringify({
plan_only: true,
workdir: workdir || null,
workspace_id: workspaceId || null,
}),
});
$("#programOut").textContent = JSON.stringify(out, null, 2);
} catch (e) {
$("#programOut").textContent = String(e);
}
}

async function submitBindProgramStep(stepId, workdir) {
async function submitBindProgramStep(stepId, workdir, workspaceId) {
try {
if (looksAbsolutePath(workdir || "")) {
throw new Error("Subpath/workdir must be relative (absolute paths rejected)");
}
const envBody = {
plan_only: false,
workdir: workspaceId ? (workdir || "") : (workdir || "ws"),
workspace_id: workspaceId || null,
};
const env = await api(`/v1/programs/${state.programId}/steps/${stepId}/run`, {
method: "POST",
body: JSON.stringify({ plan_only: false, workdir: workdir || "ws" }),
body: JSON.stringify(envBody),
});
if (!env.run_create) {
$("#programOut").textContent = JSON.stringify(env, null, 2);
Expand All @@ -634,7 +758,8 @@ async function submitBindProgramStep(stepId, workdir) {
method: "POST",
body: JSON.stringify({
bind_run_id: runId,
workdir: workdir || env.run_create.workdir || "ws",
workdir: envBody.workdir,
workspace_id: workspaceId || null,
idempotency_key: `bind-${runId}`,
}),
});
Expand Down
29 changes: 28 additions & 1 deletion console/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ <h2>Goal pack <span class="muted">(prefer for large work)</span></h2>
<select id="runMode"><option value="sync">sync</option><option value="async">async</option></select>
</label>
</div>
<div class="row">
<label>Workspace
<select id="workspaceSelect">
<option value="">sandbox — new empty workdir</option>
</select>
</label>
<label>Subpath
<input id="workspaceSubpath" type="text" placeholder="(repo root)" autocomplete="off" />
</label>
</div>
<p class="muted">Registered workspaces edit the real host tree. Sandbox is disposable under <code>.recertia/workspaces/…</code>.</p>
<div class="desired">
<h2>Desired states <span id="formSourceHint" class="muted"></span></h2>
<div id="desiredList"></div>
Expand Down Expand Up @@ -156,7 +167,23 @@ <h1>Ops — metrics & canary</h1>

<section id="view-auth" class="view">
<h1>Auth / tenant (C3–C5)</h1>
<p class="lede">Dev login issues a console session. Switch tenant when membership has more than one.</p>
<p class="lede">Dev login issues a console session. Switch tenant when membership has more than one. Register host workspaces (admin) for Pilot binds.</p>
<div class="desired">
<h2>Registered workspaces</h2>
<div class="row">
<label>ID <input id="wsId" type="text" placeholder="recertia" /></label>
<label>Display name <input id="wsName" type="text" placeholder="quantrobs/recertia" /></label>
</div>
<label>Host root (Windows)
<input id="wsHostRoot" type="text" placeholder="D:\src\recertia" autocomplete="off" />
</label>
<label>Notes <input id="wsNotes" type="text" placeholder="optional" /></label>
<div class="actions">
<button type="button" id="registerWorkspace" class="primary">Register workspace</button>
<button type="button" id="refreshWorkspaces" class="secondary">Refresh list</button>
</div>
<pre id="workspacesOut" class="panel"></pre>
</div>
<div class="actions">
<button type="button" id="devLogin" class="primary">Dev login</button>
<button type="button" id="loadMe" class="secondary">GET /v1/me</button>
Expand Down
25 changes: 25 additions & 0 deletions contracts/workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Registered host workspaces for Pilot / API workdir binding (RW0).

See ``docs/specifications/registered-workspaces.md``.
"""

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class RegisteredWorkspace(BaseModel):
"""Allowlisted host directory a tenant may bind as a run workdir."""

model_config = ConfigDict(extra="forbid")

workspace_id: str = Field(min_length=1, max_length=64)
tenant_id: str = Field(min_length=1, max_length=64)
display_name: str = Field(min_length=1, max_length=128)
host_root: str = Field(min_length=1, description="Absolute host path (Windows drive-letter)")
enabled: bool = True
created_at: datetime | None = None
created_by: str = Field(min_length=1)
notes: str | None = None
30 changes: 28 additions & 2 deletions docs/architecture/go-live.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,43 @@ python -m uvicorn recertia.api.app:app --host 127.0.0.1 --port 8080

| Surface | Use |
| --- | --- |
| Pilot | Goal form, templates, sync/async submit, live event stream |
| Pilot | Goal form, templates, sync/async submit, live event stream; workspace select |
| Runs / Skills | Browse transcripts, promote (golden-gated) |
| Tower | Proposals, jobs (`dry_run` default), practice / pressure panels |
| Metrics | `MetricReport` + canary (unavailable reasons preserved) |
| Auth | Dev login / OIDC session; tenant switcher (Phase-4 gated) |
| Auth | Dev login / OIDC session; tenant switcher; **register workspaces** (admin) |

Issue an API key with `runs` (+ `metrics` / `exec` as needed) for the sidebar. Browser
sessions (`RECERTIA_CONSOLE_AUTH=dev` or `oidc`) carry human roles; do not embed long-lived
keys in frontend source. Specs: [`../specifications/product-console.md`](../specifications/product-console.md).
Plan: [`../implementation-plan-console.md`](../implementation-plan-console.md).

### Registered workspaces (real repo bind)

Pilot cannot take raw absolute `workdir` paths. Register an allowlisted host root first
(API process must resolve Windows drive-letter paths — run uvicorn on Windows for
`D:\…` roots):

```powershell
# Admin key (or console role admin + API key with runs)
recertia keys issue --tenant default --scopes runs,admin,metrics --actor dev

recertia workspaces register `
--id recertia `
--name "quantrobs/recertia" `
--host-root D:\src\recertia `
--tenant default `
--runs-root .recertia

# CLI sugar
recertia run --goal evals/golden/repo-chore/add-editorconfig/goal.json `
--workspace-id recertia --local-exec --runs-root .recertia
```

In the console: **Auth / Tenant → Register workspace**, then Pilot → Run → Workspace
select → Submit. Spec:
[`../specifications/registered-workspaces.md`](../specifications/registered-workspaces.md).

## Soak and durability (operator GA)

Durability unit: the entire `.recertia/` tree (checkpoints, operations, ledger,
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture/product-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ quota and identity, not necessarily self-serve key minting.
- **Programs board** — durable migration programs (`/v1/programs`): ordered steps,
freeze/mutate hints, per-step preview/run/bind (see [goal-packs.md](goal-packs.md)). Distinct
from Tower **ReplayPack** evidence.
- **Workdir picker** — path or registered workspace; never accept arbitrary host escapes
beyond the existing API workdir rules.
- **Workdir picker** — sandbox (default) or **registered workspace** (allowlisted Windows
host root + optional subpath). Never accept raw absolute `workdir` on create-run; see
[registered-workspaces.md](../specifications/registered-workspaces.md).
- **Runs browser** — list/filter by tenant, task class, terminal, time; open detail.
- **Run detail** — route log, spend, manifest pins, failure class, links to transcript and
trajectory.
Expand Down
5 changes: 5 additions & 0 deletions docs/implementation-plan-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Each milestone lists **engineering gates** (merge requirements) and **explicit n
Conformance: `tests/unit/test_product_console.py` (PC-1…PC-6). C5 UI must not be marketed as
multi-tenant-safe until production-readiness criteria pass.

**Registered workspaces (Pilot real-repo bind):** planned as RW0–RW2 in
[`implementation-plan-registered-workspaces.md`](implementation-plan-registered-workspaces.md)
(spec: [`specifications/registered-workspaces.md`](specifications/registered-workspaces.md)).
Does not reopen absolute `workdir` on create-run.

---

## C0 — Read-only console (Pilot + Ops)
Expand Down
Loading
Loading