diff --git a/docs/runbooks/opax-live-deploy.md b/docs/runbooks/opax-live-deploy.md new file mode 100644 index 0000000..6687c9f --- /dev/null +++ b/docs/runbooks/opax-live-deploy.md @@ -0,0 +1,100 @@ +# OPAX Live Deployment Runbook + +## Purpose + +Use these scripts to deploy the two production components required for the +Emma/OPAX experience: + +- OPAX MCP: the conversational MCP backend and `run_emma` tool service. +- OPAX Web: the live web application served at `https://opax.vauco.no`. + +## Fixed Production Targets + +| Item | Value | +|---|---| +| Google Cloud project | `propane-will-491900-m5` | +| Region | `us-central1` | +| Artifact Registry repository | `us-central1-docker.pkg.dev/propane-will-491900-m5/osvauco-repo` | +| MCP Cloud Run service | `opax-mcp` | +| OPAX Web Cloud Run service | `opax-web` | +| Live operator URL | `https://opax.vauco.no` | + +## Normal Release Sequence + +Run commands from the repository root: + +```bash +./scripts/deploy-mcp.sh +./scripts/deploy-opax-web.sh +./scripts/check-live-services.sh +``` + +The scripts build an image, resolve its immutable digest, and deploy that digest +to the existing Cloud Run service. + +## MCP Deployment Behavior + +The existing `cloudbuild.deploy.yaml` workflow builds and pushes an MCP image. +Its internal Cloud Build deploy step can fail because the Cloud Build service +account is blocked by VPC Service Controls. + +`deploy-mcp.sh` handles that condition by resolving the image that was pushed +during the build and then deploying its immutable digest directly through the +authenticated local `gcloud` session. + +A Cloud Build failure does not automatically mean the image build failed. The +script stops if it cannot resolve an immutable pushed image digest. + +## Live Validation + +After deployment: + +1. Open `https://opax.vauco.no`. +2. Hard-refresh the browser with `Ctrl+Shift+R`. +3. Log in normally. +4. Confirm that the new Emma workspace UI is visible. +5. In a new conversation, send: + + ```text + Jeg heter Chris. + ``` + +6. In the same conversation, send: + + ```text + Hva heter jeg? + ``` + +7. Confirm that Emma uses the previous message as conversation history. +8. Start a second conversation and ask: + + ```text + Hva heter jeg? + ``` + +9. Confirm that the second conversation does not inherit context from the first + conversation. + +## Guardrails + +- Use `us-central1` only. +- Never deploy to `europe-west1`. +- Never deploy a service named `opax`. +- Deploy only the existing `opax-mcp` and `opax-web` services. +- Final Cloud Run deployment must use an immutable image digest. +- Never use a mutable tag for the final deploy. +- Never print or place secret values in scripts, logs, documentation, or Git. +- Do not deploy from Gemini without explicit human approval. +- Do not change IAM, service accounts, secrets, VPC settings, DNS, OAuth, or + Cloud Run networking as part of a normal application release. + +## Rollback + +Use one of these existing rollback methods: + +1. In Cloud Run, route traffic back to the prior ready revision. +2. Re-run the relevant deployment script after replacing the image digest with a + previously known good immutable digest. + +Use `./scripts/check-live-services.sh` to record the currently active revisions +and images before a release. \ No newline at end of file diff --git a/opax-mcp/emma_adapter.py b/opax-mcp/emma_adapter.py index 2db740c..b3a6437 100644 --- a/opax-mcp/emma_adapter.py +++ b/opax-mcp/emma_adapter.py @@ -47,4 +47,5 @@ class CanonicalEmma: self._model, prompt, composed_system_prompt, + history=history or [], ) diff --git a/opax-mcp/manifests/tools/run_emma.md b/opax-mcp/manifests/tools/run_emma.md new file mode 100644 index 0000000..dcb0a91 --- /dev/null +++ b/opax-mcp/manifests/tools/run_emma.md @@ -0,0 +1,163 @@ +# run_emma / emma.chat Component Manifest + +**Status:** Draft
+**Runtime effect:** None
+**Governing standard:** [`INCU Master Constitution`](../../../docs/INCU_Master_Constitution.md)
+**Template:** [`INCU Component Manifest Template`](../../../docs/templates/component_manifest.md)
+**Review date:** 2026-10-18
+ +> This is a documentation-only draft manifest for an existing runtime +> capability. It does not activate, disable, expand, or otherwise modify the +> `run_emma` tool, the `emma.chat` capability, Emma's prompt, Emma's authority, +> A2H2A behavior, MCP behavior, or any external-system permission. + +## Identity + +- Component ID: emma.chat +- Component name: run_emma +- Class: Orchestrator +- Version: 0.1.0-draft +- Owner: Platform Engineering +- Status: draft +- Review date: 2026-10-18 + +Current runtime capability status: + +ACTIVE in the existing capability registry. + +Current manifest governance status: + +draft. + +Reason: + +The manifest is documentation-only. No manifest validator or runtime +manifest-enforcement mechanism exists yet. + +## Purpose + +- Intended outcome: Provide conversational reasoning through the canonical Emma + runtime for a user-supplied prompt, while retaining the canonical system + prompt and appending only the read-only capability-status system context. +- Value to system: Provides a single canonical entry point for conversational + AI reasoning, decoupled from specific models or backends, and supplies + governance documentation for future manifest-driven orchestration. +- Explicit non-goals: + - No tool execution. + - No repository or file access. + - No deployment or infrastructure action. + - No external-system action, integration operation, or state-changing service + call. The existing model-inference request remains part of the current + canonical conversational runtime path only. + - No memory or document retrieval. + - No ticket creation, ticket approval, or A2H2A execution. + - No write, deletion, permission, scheduling, or communication action. + - No delegation to additional agents. + - No independent completion claim beyond the returned conversational response. + +## Scope + +- Permitted tasks: + - Accept a user-provided prompt through the existing canonical run_emma path. + - Compose the canonical Emma system prompt with read-only capability context. + - Return the model's conversational response. +- Prohibited tasks: Any task corresponding to a planned or forbidden + capability. This manifest does not activate any capability. +- Supported systems/environments: Existing opax-mcp runtime path. +- Time/cost/retry limits: Not currently declared in the runtime capability + registry. No additional retry, time, or cost authority is granted by this + manifest. + +## Authority + +- Read permissions: User-provided prompt and existing canonical + prompt/capability-context inputs within the current request path only. +- Write permissions: None. +- Approval requirement: No external or state-changing action is within scope. + Any future external or irreversible action requires a separate in-scope + manifest, exact explicit human approval, and platform authorization. +- Delegation rules: No delegation to subordinate agents, tools, connectors, + workflows, or operators is implemented or authorized. +- Revocation method: Disable or remove the existing runtime capability through + the authoritative runtime capability/tool configuration and platform + controls; this draft document alone does not revoke runtime access. + +## Inputs and Outputs + +- Required inputs: A user prompt supplied through the canonical run_emma + request path. +- Optional inputs: None declared for this manifest. +- Outputs: The existing raw conversational response returned by the canonical + Emma adapter/runtime path. +- Source of truth: Current capability registry for capability status; current + source code and tests for runtime contract; platform runtime for actual + service state. +- Evidence format: Existing unit-test output, capability-registry state, + adapter call contract, and runtime health/response evidence where separately + verified. + +## INCU Mandate + +- Outcome statement format: Return a bounded conversational response to a + user-supplied prompt without claiming unavailable authority. +- Startability rule: Begin only with the current user-provided prompt and the + existing read-only runtime path. +- Applicable levers: Interest, Novelty, Challenge, Urgency, Purpose — future + governance reference only; no runtime behavior change is introduced by this + manifest. +- Boundedness rule: One user prompt, current canonical runtime path, no + external-system action, no tool delegation, no state mutation, and no + authority expansion. +- Restart artifact: Not currently implemented for single-request conversational + handling. +- Blocker behavior: If a request requires a missing capability, tool, external + action, write, deployment, repository operation, memory/document access, or + unapproved delegation, state that it is unavailable and require a separate + approved manifest and explicit human authorization before any future + implementation. + +## Safety and Governance + +- Data classification: User-provided request content; classification and + retention are governed by existing platform policy and are not changed by + this manifest. +- Security constraints: Secrets must not be intentionally included in prompts + or outputs. This draft manifest does not implement secret detection or + redaction. No tool execution or external-system action is authorized, and + existing platform authentication and authorization remain controlling. +- Privacy constraints: Do not claim retention, deletion, or consent guarantees + not established by current implementation. +- Stop conditions: Missing capability, external or state-changing request, + request outside current conversational scope, missing approval for a future + consequential action, or evidence conflict. +- Escalation path: Accountable human owner / Platform Engineering. +- Audit events: No new INCU audit event is created by this draft manifest. + Existing runtime and A2H2A audit behavior remain unchanged. + +## A2HA Contract + +- Ticket fields read: None. +- Ticket fields written: None. +- Allowed state transitions: None. +- Required evidence before transition: Not applicable, because this component + has no A2HA write authority. +- Comment/update policy: No A2HA comment or update authority is granted. + +## Evaluation + +- Acceptance tests: + - Capability registry retains emma.chat as ACTIVE. + - Capability bridge remains deterministic and read-only. + - Canonical Emma adapter retains the original user prompt. + - Capability context is appended only to the system prompt. + - No tool, connector, external request, repository, deployment, memory, + document-retrieval, or state-changing capability is activated. +- Reliability metrics: Existing test success/failure and runtime health + evidence; no new metrics system is introduced. +- Safety metrics: + - Unauthorized-write rate must remain zero. + - Unauthorized tool/delegation rate must remain zero. +- Review/rollback procedure: Review this draft on or before 2026-10-18. To + pause or change runtime behavior, use the existing capability/tool + configuration and human-approved change process. This draft document has no + runtime rollback effect. diff --git a/opax-mcp/server.py b/opax-mcp/server.py index a7d87d3..0c2c5a3 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -726,13 +726,19 @@ async def trigger_build(p: dict) -> dict: # Ollama helpers — direkte mot emma-gpu-vm # --------------------------------------------------------------------------- -async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict: +async def _ollama_chat(model: str, prompt: str, system: str = "", history: Optional[List[Dict[str, str]]] = None) -> dict: # ... (beholdt uendret) messages = [] if system: messages.append({"role": "system", "content": system}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) - payload = { "model": model, "messages": messages, "stream": False } + payload = { + "model": model, + "messages": messages, + "stream": False + } try: async with httpx.AsyncClient(timeout=120) as c: r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload) @@ -873,12 +879,38 @@ async def tui_command(p): return await _agent_post("/tui-comman async def run_jason(p): """Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk.""" return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")}) +def _normalize_emma_history(value): + if not isinstance(value, list): + return [] + + normalized = [] + for entry in value: + if not isinstance(entry, dict): + continue + + role = entry.get("role") + content = entry.get("content") + + if role not in {"user", "assistant"}: + continue + + if not isinstance(content, str): + continue + + content = content.strip() + if not content: + continue + + normalized.append({"role": role, "content": content}) + + return normalized[-40:] + async def run_emma(p: dict) -> dict: """Kaller den kanoniske Emma-agenten med en prompt.""" capability_system_context = build_capability_system_context() return await canonical_emma.run( prompt=p.get("prompt", p.get("message", "")), - history=[], + history=_normalize_emma_history(p.get("history")), system_context=capability_system_context, ) async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent...")) @@ -1164,7 +1196,7 @@ TOOLS = { ), # AI Agents "run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}), - "run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}), + "run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type": "object", "properties": {"prompt": {"type": "string"}, "history": {"type": "array", "items": {"type": "object", "properties": {"role": {"type": "string", "enum": ["user", "assistant"]}, "content": {"type": "string"}}}}}}), # Local models — read-only discovery "list_emma_models": ( diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js index c18ae50..df92456 100644 --- a/opax-web/backend/routes/consoleApi.js +++ b/opax-web/backend/routes/consoleApi.js @@ -67,8 +67,24 @@ router.get('/approvals', (req, res) => { }); }); +function normalizeHistory(value) { + if (!Array.isArray(value)) return []; + + return value + .filter( + (entry) => + entry && + typeof entry === 'object' && + (entry.role === 'user' || entry.role === 'assistant') && + typeof entry.content === 'string' && + entry.content.trim().length > 0 + ) + .slice(-40) + .map(({ role, content }) => ({ role, content: content.trim() })); +} + router.post('/emma/chat', async (req, res) => { - const { message, session_id, ticket_number } = req.body; + const { message, session_id, ticket_number, history } = req.body; if (!message || typeof message !== 'string' || message.length > 4096) { return res.status(400).json({ status: 'error', message: 'Invalid message.'}); @@ -79,7 +95,10 @@ router.post('/emma/chat', async (req, res) => { } try { - const emmaData = await callAgent('run_emma', { prompt: message, history: [] }); + const emmaData = await callAgent('run_emma', { + prompt: message, + history: normalizeHistory(history), + }); res.json({ status: 'success', diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx index 76d6eb0..623819c 100644 --- a/opax-web/frontend/src/App.tsx +++ b/opax-web/frontend/src/App.tsx @@ -8,30 +8,32 @@ interface User { function App() { const [user, setUser] = useState(null); + const [authChecked, setAuthChecked] = useState(false); useEffect(() => { fetch('/auth/me', { credentials: 'include' }) .then((r) => (r.ok ? r.json() : null)) - .then(setUser); + .then(setUser) + .finally(() => setAuthChecked(true)); }, []); + if (!authChecked) { + return null; // or a loading spinner + } + if (!user) { return ( -
+

OPAX

Continue with Google -
+ ); } return ( -
-
- OPAX / Operations Console - {user.email} -
- -
+
+ +
); } diff --git a/opax-web/frontend/src/components/ChatWorkspace.tsx b/opax-web/frontend/src/components/ChatWorkspace.tsx index 670cf03..5be2b1a 100644 --- a/opax-web/frontend/src/components/ChatWorkspace.tsx +++ b/opax-web/frontend/src/components/ChatWorkspace.tsx @@ -1,105 +1,160 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; -interface Message { - role: string; - content: string; +export interface Message { + id: string; + role: 'user' | 'assistant' | 'status'; + content: string; + isError?: boolean; + retryPayload?: Message; } -const ChatWorkspace = () => { - const [message, setMessage] = useState(''); - const [transcript, setTranscript] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [emmaStatus, setEmmaStatus] = useState('unavailable'); - const [ticketNumber, setTicketNumber] = useState(null); +interface ChatWorkspaceProps { + messages: Message[]; + isLoading: boolean; + onSendMessage: (message: string) => void; + onRetry: (originalMessage: Message, errorStatusId: string) => void; + composerValue: string; + setComposerValue: (value: string) => void; + composerRef: React.RefObject; +} - useEffect(() => { - const fetchEmmaStatus = async () => { - try { - const res = await fetch('/api/console/emma/status', { credentials: 'include' }); - const data = await res.json(); - setEmmaStatus(data.status); - } catch (e) { - setEmmaStatus('unavailable'); - } - }; - fetchEmmaStatus(); - }, []); +const StarterPrompts = ({ onSelect }: { onSelect: (prompt: string) => void }) => { + const prompts = [ + 'Gi meg en kort status på det viktigste jeg bør følge opp.', + 'Hjelp meg å strukturere neste arbeidsøkt.', + 'Oppsummer risikoer og neste beslutninger.', + 'Lag et utkast til en konkret oppfølgingsplan.', + ]; - const handleSend = async () => { - if (!message.trim()) return; + return ( +
+
+

Hva vil du få oversikt over i dag?

+

Emma hjelper deg å strukturere arbeid, analysere situasjoner og finne tydelige neste steg.

+
+ {prompts.map((p) => ( + + ))} +
+
+ ); +}; - const newMessage: Message = { role: 'user', content: message }; - setTranscript(prev => [...prev, newMessage]); - setMessage(''); - setLoading(true); - setError(null); +const PromptComposer = ({ onSend, isLoading, value, setValue, composerRef }: { + onSend: (val: string) => void; + isLoading: boolean; + value: string; + setValue: (val: string) => void; + composerRef: React.RefObject; +}) => { + useEffect(() => { + if (composerRef.current) { + composerRef.current.style.height = 'auto'; + composerRef.current.style.height = `${composerRef.current.scrollHeight}px`; + } + }, [value, composerRef]); - try { - const res = await fetch('/api/console/emma/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ message, ticket_number: ticketNumber }), - }); + const handleSend = () => { + if (value.trim()) onSend(value.trim()); + }; - if (!res.ok) { - throw new Error('Emma is unavailable.'); - } + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; - const data = await res.json(); - const emmaMessage: Message = { role: 'assistant', content: data.reply }; - setTranscript(prev => [...prev, emmaMessage]); - - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

Emma Local

- {emmaStatus === 'ready' ? 'Connected' : 'Unavailable'} - {ticketNumber && Gitea #{ticketNumber}} -
-
- {transcript.length === 0 && !loading && ( -
-

Hei Chris. Hva vil du at jeg skal få gjort?

-
- - - - - -
-
- )} - {transcript.map((msg, index) => ( -
- {msg.role}: {msg.content} -
- ))} - {loading &&
...
} - {error &&
{error}
} -
-
-