fix(stop+stability): stop button interrupts live generation; classifier, prompt + preview pane improvements
Stop button fix: - Plumb AbortSignal end-to-end: callVibnChat → Gemini SDK (config.abortSignal) / OpenAI fetch → executeMcpTool (/api/mcp fetch) - Treat abort as clean user stop (not fatal error); partial reply persisted with '(stopped by user)' Classifier fix: - Add timeout/gateway/5xx/connection-error vocabulary to diagnose intent - Prevents 'I get a gateway timeout' from falling through to feature_build (40 rounds) and looping Prompt / agent behaviour: - Render verification is now scope-aware: small edits stop at green healthCheck; no browser_console/curl audit on healthy server - Sanitize stale '### Phase Checkpoint' walls from loaded history so old threads stop biasing new turns - Next.js dev command updated to --no-turbopack for container stability (per-route lazy compile caused cold-start 503s) - New public page prompt: agent checks middleware allowlist in the same turn - Scope discipline and QA-tool gating carried forward from prior session Code cleanup: - Remove duplicate AgentPhase declaration (TS2440) - Remove dead checkpoint emit branch and orphan 'checkpoint' phase value - Remove unused MAX_TOOL_ROUNDS constant Preview pane (build status): - 4-state machine: initial-load / building (with elapsed timer) / build-failed / not-running - pollMs 0 → 5 000ms so dev-server recovery and build completion auto-update without refresh - anatomy route + use-anatomy type: inFlightBuild gains createdAt for elapsed timer
This commit is contained in:
@@ -51,23 +51,35 @@ DO NOT treat `master-ai` as a single monorepo on Gitea. You must push changes in
|
||||
│ Remote 'coolify_agent_gitea' -> https://git.vibnai.com/mark/vibn-agent-runner.git
|
||||
├── vibn-frontend/ <-- Subfolder of master-ai. Pushes via:
|
||||
│ Remote 'coolify_gitea' -> https://git.vibnai.com/mark/vibn-frontend.git
|
||||
└── vibn-api/ <-- Subfolder of master-ai. Pushes via:
|
||||
Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
|
||||
├── vibn-api/ <-- Subfolder of master-ai. Pushes via:
|
||||
│ Remote 'coolify_api_gitea' -> https://git.vibnai.com/mark/vibn-api.git
|
||||
└── vibn-telemetry-service/ <-- Subfolder of master-ai (Training Data Microservice). Pushes via:
|
||||
Remote 'coolify_telemetry_gitea' -> https://git.vibnai.com/mark/vibn-telemetry-service.git
|
||||
```
|
||||
|
||||
### Git Remotes Reference (Configured in `/Users/markhenderson/master-ai`):
|
||||
* `coolify_agent_gitea` : `https://git.vibnai.com/mark/vibn-agent-runner.git`
|
||||
* `coolify_gitea` : `https://git.vibnai.com/mark/vibn-frontend.git`
|
||||
* `coolify_api_gitea` : `https://git.vibnai.com/mark/vibn-api.git`
|
||||
* `gitea` : `https://git.vibnai.com/mark/master-ai.git` *(share-only: for a coworker's local setup; **builds do NOT use this**)*
|
||||
* `origin` : `https://github.com/MawkOne/master-ai.git` *(GitHub mirror)*
|
||||
* `coolify_agent_gitea` : `https://git.vibnai.com/mark/vibn-agent-runner.git`
|
||||
* `coolify_gitea` : `https://git.vibnai.com/mark/vibn-frontend.git`
|
||||
* `coolify_api_gitea` : `https://git.vibnai.com/mark/vibn-api.git`
|
||||
* `coolify_telemetry_gitea` : `https://git.vibnai.com/mark/vibn-telemetry-service.git`
|
||||
* `gitea` : `https://git.vibnai.com/mark/master-ai.git` *(share-only: for a coworker's local setup; **builds do NOT use this**)*
|
||||
* `origin` : `https://github.com/MawkOne/master-ai.git` *(GitHub mirror)*
|
||||
|
||||
**How deploys actually work:** `master-ai` is a single git repo. Each cloud app builds from its **own** Gitea
|
||||
remote, from the matching subfolder. To ship a change, commit in `master-ai`, then
|
||||
`git push <remote> HEAD:main` (e.g. `git push coolify_agent_gitea HEAD:main` for the runner), then trigger the
|
||||
`git push <remote> HEAD:main` (e.g. `git push coolify_telemetry_gitea HEAD:main`), then trigger the
|
||||
Coolify deploy for that app (see `VIBNDEV.md`). `vibn-code` is a nested submodule with its own `.git` — commit &
|
||||
push it via its own `origin`. Secret `.env*` files at the repo root are gitignored — never commit them.
|
||||
|
||||
### Deploying the Telemetry Service manually via Coolify UI:
|
||||
Because Coolify's API strictly blocks the programmatic creation of GitHub/Gitea Apps, the Telemetry service must be linked manually once:
|
||||
1. Open [Coolify Dashboard -> vibn-infrastructure -> production](https://coolify.vibnai.com/project/f4owwggokksgw0ogo0844os0/environment/foskksoccksk0kc4g8sk88ok)
|
||||
2. Click **+ Add -> Application -> Private Repository (with Gitea)**.
|
||||
3. Select `vibn-telemetry-service` and branch `main`.
|
||||
4. Set Build Pack to `Dockerfile` and Ports Exposes to `4000`.
|
||||
5. Under Environment Variables, add `DATABASE_URL=postgresql://<user>:<password>@<host>/<database>`
|
||||
6. Deploy it, then add `TELEMETRY_SERVICE_URL=http://<the-new-coolify-url>:4000` to the `vibn-frontend` environments.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Tech Stacks & Development Tools
|
||||
|
||||
@@ -19,15 +19,47 @@ function sandboxIframe(src: string, origin: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a deployment has been running, formatted as "1m 23s" */
|
||||
function useElapsed(sinceIso: string | undefined) {
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
useEffect(() => {
|
||||
// No ISO timestamp — nothing to tick. The caller won't render
|
||||
// the elapsed string when sinceIso is undefined anyway.
|
||||
if (!sinceIso) return;
|
||||
const update = () => {
|
||||
const ms = Date.now() - new Date(sinceIso).getTime();
|
||||
if (ms < 0) return;
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
setElapsed(m > 0 ? `${m}m ${s % 60}s` : `${s}s`);
|
||||
};
|
||||
update();
|
||||
const id = setInterval(update, 1000);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
setElapsed("");
|
||||
};
|
||||
}, [sinceIso]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
export default function PreviewTab() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 0 });
|
||||
|
||||
// Poll every 5 s so build-state transitions surface without a manual refresh.
|
||||
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 5000 });
|
||||
|
||||
const previews = anatomy?.hosting.previews ?? [];
|
||||
// Find the port 3000 preview if it exists, otherwise fall back to null
|
||||
const primaryPreview = previews.find((p) => p.port === 3000);
|
||||
|
||||
// Derive in-flight / recently-failed build from prod apps.
|
||||
const liveApps = anatomy?.hosting.live ?? [];
|
||||
const inFlightApp = liveApps.find((a) => a.inFlightBuild);
|
||||
const failedApp = !inFlightApp
|
||||
? liveApps.find((a) => a.lastBuild?.status === "failed")
|
||||
: undefined;
|
||||
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const bridge = usePreviewBridge();
|
||||
@@ -45,6 +77,14 @@ export default function PreviewTab() {
|
||||
bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc);
|
||||
}, [bridge, iframeSrc]);
|
||||
|
||||
// Derive content for the empty state.
|
||||
const emptyContent = (() => {
|
||||
if (loading && !anatomy) return <InitialLoader />;
|
||||
if (inFlightApp) return <BuildingState app={inFlightApp} />;
|
||||
if (failedApp) return <FailedState app={failedApp} />;
|
||||
return <NotRunningState />;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div style={canvas}>
|
||||
<div
|
||||
@@ -64,14 +104,7 @@ export default function PreviewTab() {
|
||||
>
|
||||
{deviceMode === "mobile" && <MobileChrome />}
|
||||
|
||||
{loading && !iframeSrc ? (
|
||||
<div style={loaderWrap}>
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||||
/>
|
||||
</div>
|
||||
) : iframeSrc ? (
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
key={`${iframeSrc}-${refreshKey}`}
|
||||
src={iframeSrc}
|
||||
@@ -90,12 +123,7 @@ export default function PreviewTab() {
|
||||
: {})}
|
||||
/>
|
||||
) : (
|
||||
<div style={loaderWrap}>
|
||||
<p style={emptyText}>Preview not running on port 3000.</p>
|
||||
<p style={{ ...emptyText, fontSize: "0.75rem", marginTop: 8 }}>
|
||||
Ask the AI to start the dev server.
|
||||
</p>
|
||||
</div>
|
||||
<div style={loaderWrap}>{emptyContent}</div>
|
||||
)}
|
||||
|
||||
{deviceMode === "mobile" && <div style={homeIndicator} aria-hidden />}
|
||||
@@ -105,6 +133,132 @@ export default function PreviewTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty-state sub-components ────────────────────────────────────────────────
|
||||
|
||||
function InitialLoader() {
|
||||
return (
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildingState({
|
||||
app,
|
||||
}: {
|
||||
app: {
|
||||
name: string;
|
||||
inFlightBuild?: { status: string; createdAt?: string; finishedAt?: string };
|
||||
};
|
||||
}) {
|
||||
const elapsed = useElapsed(app.inFlightBuild?.createdAt);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
|
||||
>
|
||||
<div style={buildRingOuter}>
|
||||
<div style={buildRingInner} className="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ ...emptyTitle, color: "#3b82f6" }}>Building {app.name}</p>
|
||||
<p style={emptySubtext}>
|
||||
Your app is compiling and deploying. The preview will load automatically
|
||||
when it's ready.
|
||||
</p>
|
||||
|
||||
<div style={statusPill}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "#3b82f6",
|
||||
marginRight: 6,
|
||||
verticalAlign: "middle",
|
||||
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ verticalAlign: "middle" }}>
|
||||
{app.inFlightBuild?.status ?? "building"}
|
||||
{elapsed ? ` · ${elapsed}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedState({
|
||||
app,
|
||||
}: {
|
||||
app: { name: string; lastBuild?: { status: string; commit?: string } };
|
||||
}) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "#fef2f2",
|
||||
border: "1.5px solid #fecaca",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 14px",
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
|
||||
<p style={{ ...emptyTitle, color: "#ef4444" }}>Build failed</p>
|
||||
<p style={emptySubtext}>
|
||||
The last deploy of <strong>{app.name}</strong> didn't succeed.
|
||||
{app.lastBuild?.commit ? ` (${app.lastBuild.commit.slice(0, 7)})` : ""}
|
||||
</p>
|
||||
<p style={{ ...emptySubtext, marginTop: 8 }}>
|
||||
Ask the AI to check the build logs and fix the issue.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotRunningState() {
|
||||
return (
|
||||
<div style={{ textAlign: "center", maxWidth: 260 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "#f4f4f5",
|
||||
border: "1.5px solid #e4e4e7",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 14px",
|
||||
fontSize: 18,
|
||||
color: "#a1a1aa",
|
||||
}}
|
||||
>
|
||||
⏸
|
||||
</div>
|
||||
<p style={emptyTitle}>Preview not running</p>
|
||||
<p style={emptySubtext}>No dev server found on port 3000.</p>
|
||||
<p style={{ ...emptySubtext, marginTop: 4 }}>
|
||||
Ask the AI to start the dev server.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -164,10 +318,51 @@ const loaderWrap: React.CSSProperties = {
|
||||
background: "#fff",
|
||||
};
|
||||
|
||||
const emptyText: React.CSSProperties = {
|
||||
const emptyTitle: React.CSSProperties = {
|
||||
margin: "0 0 6px",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: 600,
|
||||
color: "#18181b",
|
||||
letterSpacing: "-0.01em",
|
||||
};
|
||||
|
||||
const emptySubtext: React.CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: "0.85rem",
|
||||
color: "#a1a1aa",
|
||||
fontSize: "0.78rem",
|
||||
color: "#71717a",
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const statusPill: React.CSSProperties = {
|
||||
display: "inline-block",
|
||||
marginTop: 14,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 20,
|
||||
background: "#eff6ff",
|
||||
border: "1px solid #bfdbfe",
|
||||
fontSize: "0.72rem",
|
||||
color: "#1d4ed8",
|
||||
};
|
||||
|
||||
const buildRingOuter: React.CSSProperties = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: "#eff6ff",
|
||||
border: "2px solid #bfdbfe",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
const buildRingInner: React.CSSProperties = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
borderTop: "2.5px solid #3b82f6",
|
||||
borderRight: "2.5px solid transparent",
|
||||
borderBottom: "2.5px solid transparent",
|
||||
borderLeft: "2.5px solid transparent",
|
||||
};
|
||||
|
||||
function MobileChrome() {
|
||||
|
||||
@@ -47,13 +47,9 @@ type TurnIntent =
|
||||
| "deploy"
|
||||
| "autonomous";
|
||||
|
||||
type AgentPhase =
|
||||
| "plan"
|
||||
| "recon"
|
||||
| "checkpoint"
|
||||
| "execute"
|
||||
| "verify"
|
||||
| "final";
|
||||
// AgentPhase is imported from "@/lib/ai/vibn-tools" (single source of truth).
|
||||
// A duplicate local declaration here previously conflicted with that import
|
||||
// (TS2440) and broke typechecking.
|
||||
|
||||
const TOOL_BUDGETS: Record<TurnIntent, number> = {
|
||||
conversational: 1, // Must be at least 1 so the LLM gets called for a text reply
|
||||
@@ -92,9 +88,13 @@ function classifyTurnIntent(message: string): TurnIntent {
|
||||
return "small_fix";
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
// Diagnostics — error/failure vocabulary including infra/network errors.
|
||||
// "timeout", "gateway", "502/503/504", "connection refused" are infrastructure
|
||||
// failure signals that the model should diagnose and report on, not treat as
|
||||
// a 40-round build task. Without these, "I get a gateway timeout" falls through
|
||||
// to feature_build and burns 40 rounds looping on a dead dev server.
|
||||
if (
|
||||
/(why|broken|error|blank|not loading|fail|bug|issue|doesn't work|isn't working|fix)/.test(
|
||||
/(why|broken|error|blank|not loading|fail|bug|issue|doesn't work|isn't working|fix|time.?out|tim(?:es?|ed|ing) out|gateway|5[0-2][0-9]|connection (refused|reset|failed)|unreachable|can.?t connect|cannot connect|not respond)/.test(
|
||||
m,
|
||||
)
|
||||
)
|
||||
@@ -158,10 +158,10 @@ import { execInDevContainer } from "@/lib/dev-container";
|
||||
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
|
||||
import { logTurnSummary } from "@/lib/ai/telemetry-db";
|
||||
|
||||
// C-01: Raised to 150. Provides a virtually unlimited, elite engineering runway
|
||||
// for complex custom application building, while the State-Based
|
||||
// Governor acts as our real-time safetynet to stop loops within 2 rounds.
|
||||
const MAX_TOOL_ROUNDS = 150;
|
||||
// Per-turn tool budgets are intent-based (see TOOL_BUDGETS below); the
|
||||
// State-Based Governor is the real-time safety net that breaks loops within a
|
||||
// couple of rounds. (A former flat MAX_TOOL_ROUNDS cap was removed once
|
||||
// TOOL_BUDGETS replaced it.)
|
||||
|
||||
let chatTablesReady = false;
|
||||
async function ensureChatTables() {
|
||||
@@ -278,7 +278,8 @@ Your turn ends when the user's PRD is saved via plan_vision_set, decisions are l
|
||||
# MODE: Vibe Code (Full Engineering)
|
||||
You are a Lead Software Engineer who is permitted to write code, edit files, create backend endpoints, and deploy apps.
|
||||
- Use \`fs_write\`, \`fs_edit\`, \`ship\`, and other developer tools directly to build features based on the saved Plan.
|
||||
- Always run \`request_visual_qa\` before returning a preview URL to the user to guarantee visual quality.
|
||||
- **Do EXACTLY what the user asked — nothing more.** For a small or scoped change ("remove a word", "change a color", "fix this link"), make that single change, confirm it still compiles, and STOP. Do NOT refactor, restyle, optimize images, change breakpoints, rewrite files, or fix unrelated things you happen to notice.
|
||||
- \`request_visual_qa\` is OPTIONAL and only appropriate for from-scratch page builds or when the user explicitly asks for design/visual work. **Do NOT run it for small edits.** When you do run it, its critique is ADVISORY only: address solely the points that relate to the user's actual request, and NEVER let it expand the scope of the task. A QA critique about an unrelated part of the page is not your job this turn.
|
||||
`;
|
||||
|
||||
const projectsText = projects.length
|
||||
@@ -372,6 +373,13 @@ If you are unsure which mode the user is in, **default to CONVERSATIONAL** and a
|
||||
## Identity
|
||||
You are a high-agency product engineer. You own the outcome. Continue until the user's goal is actually resolved unless you're blocked on missing info, proceeding would be unsafe, or the user changes direction. You are not answering questions; you are building with the user. Translate engineering complexity into product momentum.
|
||||
|
||||
## Scope discipline (READ THIS)
|
||||
"The user's goal" means **exactly what they asked for in their message — no more.** High agency is about *finishing the requested task well*, NOT about expanding it. Specifically:
|
||||
- A one-line request gets a one-line change. "Remove the word 'Hardened'" = delete that word, confirm it compiles, done. It is NOT permission to redesign the navbar, switch \`<img>\` to \`next/image\`, change breakpoints, touch global CSS, or rewrite the file.
|
||||
- Do NOT fix bugs, refactor, restyle, or "improve" things the user didn't ask about — even if you notice them. If you spot something worth doing, MENTION it in your final reply and let the user decide; do not just do it.
|
||||
- Your "done" condition is the user's request being satisfied and the app still building — NOT a perfect visual-QA score. Chasing QA critiques on a scoped edit is scope creep and is a failure of judgment.
|
||||
- When in doubt about whether something is in scope, it is NOT. Make the asked-for change and stop.
|
||||
|
||||
## Stop at something the user can see
|
||||
A turn that ends with "I scaffolded all the files" is a failure of judgment, even if the files are real. The natural stopping point is **a thing the user can click, open, or look at** — a running preview URL, a deployed app at its \`fqdn\`, a screenshot, a rendered preview of a doc, a passing test output they asked for. Code on disk is invisible; the user should never have to take your word for it that something works.
|
||||
|
||||
@@ -431,11 +439,12 @@ Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` an
|
||||
- **Directory:** The command runs from the root \`/workspace\` directory. Cwd is automatically set to \`/workspace\`. You do NOT need to run \`cd\` commands. Example: \`command: \"npm run dev\"\`.
|
||||
- \`dev_server_stop\` / \`dev_server_list\` / \`dev_server_logs\` — use only AFTER a failed start, and only to diagnose the error the function returned. Never on success.
|
||||
|
||||
**Verify the page actually renders:**
|
||||
- After \`dev_server_start\` returns a \`previewUrl\` AND \`healthCheck.status === 200\`, call \`browser_console { url: previewUrl }\` to capture frontend console errors.
|
||||
- **CRITICAL:** Next.js HMR overlay syntax errors do NOT crash the \`dev_server_start\` command. Even if \`dev_server_start\` returns \`Status: success\`, you MUST call \`browser_console\` to verify that there are no red syntax error overlays on the screen. If \`browser_console\` returns errors, fix them with \`fs_edit\` before declaring done. A green \`healthCheck\` plus a clean console is the real "done" signal for UI work.
|
||||
**Verify the page renders (scope-aware — do NOT over-verify):**
|
||||
- For a **from-scratch page/app build**: after \`dev_server_start\` returns a \`previewUrl\` AND \`healthCheck.status === 200\`, you MAY call \`browser_console { url: previewUrl }\` ONCE to catch red Next.js HMR syntax-error overlays (these don't fail \`dev_server_start\`). Fix any console errors with \`fs_edit\`, then share the previewUrl. Run this check AT MOST once.
|
||||
- For a **small or scoped edit** (changing text/a color/a link/a prop, or adding one simple page): a green \`healthCheck.status === 200\` IS the done signal. **Do NOT run \`browser_console\`, \`browser_navigate\`, \`dev_server_logs\`, or \`curl\` audits on a healthy server** — share the \`previewUrl\` and stop.
|
||||
- Only escalate to the BLANK PREVIEW protocol below when there is an ACTUAL trouble signal: a **non-200 healthCheck**, a **failed \`dev_server_start\`**, or the **user reporting** the page is broken/blank. A single timed-out \`browser_navigate\` is NOT, by itself, proof the page is broken — do NOT start looping on logs/curl because of one timeout.
|
||||
|
||||
**BLANK PREVIEW / NOT LOADING PROTOCOL:**
|
||||
**BLANK PREVIEW / NOT LOADING PROTOCOL (only on a real trouble signal above):**
|
||||
If the user tells you the preview is blank, not loading, or shows nothing:
|
||||
1. **DO NOT GUESS OR EDIT CODE YET.**
|
||||
2. Run \`dev_server_list\` to check if the server is actually running.
|
||||
@@ -447,19 +456,22 @@ If the user tells you the preview is blank, not loading, or shows nothing:
|
||||
|
||||
**HMR through the proxy (apply when scaffolding):**
|
||||
- **Vite (verified working):** in \`vite.config\` set \`server: { host: '0.0.0.0', port: <3000-3009>, strictPort: true, hmr: { clientPort: 443, protocol: 'wss', host: '<the previewUrl host, no protocol>' } }\`. The \`hmr.host\` is REQUIRED — without it Vite's HMR client can guess the wrong host and the WS handshake fails through Traefik. Default localhost binding looks fine locally but breaks HMR through the proxy.
|
||||
- **Next dev:** \`next dev -p 3000 -H 0.0.0.0\` (WSS HMR works automatically through the proxy without extra config).
|
||||
- **Next dev:** \`next dev -H 0.0.0.0 --no-turbopack\` (WSS HMR works automatically through the proxy without extra config). **Always use \`--no-turbopack\`** — Turbopack\'s per-route lazy compilation causes cold-start 503s in the remote container (the health probe passes on \`/\` but unvisited routes hang on first hit until Turbopack compiles them). webpack compiles all routes upfront and is significantly more stable in a containerised environment.
|
||||
- **Express / plain Node:** bind \`0.0.0.0\` (we set \`HOST=0.0.0.0\` env, but verify your framework respects it).
|
||||
|
||||
**Build-me-X recipe:** \`devcontainer_ensure\` → \`apps_templates_scaffold { templateName }\` (if matching "dashboard" or "pitch-deck") OR \`shell_exec npx create-next-app@latest . --yes\` → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'npm run dev', port: 3000 }\` and **share the previewUrl in your reply — that's the turn's stopping point**. When the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). If a project is multi-service (frontend + API + worker), pick the user-facing service (usually the frontend) and start ITS dev server first, even if the others aren't done yet — a clickable shell beats a complete-but-invisible stack.
|
||||
**Build-me-X recipe:** \`devcontainer_ensure\` → \`apps_templates_scaffold { templateName }\` (if matching "dashboard" or "pitch-deck") OR \`shell_exec npx create-next-app@latest . --yes\` → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'next dev -H 0.0.0.0 --no-turbopack', port: 3000 }\` and **share the previewUrl in your reply — that's the turn's stopping point**. When the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). If a project is multi-service (frontend + API + worker), pick the user-facing service (usually the frontend) and start ITS dev server first, even if the others aren't done yet — a clickable shell beats a complete-but-invisible stack.
|
||||
|
||||
**Sentry is auto-provisioned per Vibn project.** When you scaffold a Next.js or Vite app, wire Sentry from day one so the user gets de-minified error capture + Session Replay on first deploy. The DSN (\`NEXT_PUBLIC_SENTRY_DSN\`) and shared org auth token (\`SENTRY_AUTH_TOKEN\`) are injected into the Coolify app's env automatically by \`apps_create\` — you don't set them. Get the project's Sentry slug from \`projects_get { projectId }\` (field: \`sentry.slug\`); pass it to \`withSentryConfig({ org: "vibnai", project: "<slug>", ... })\`. The reference recipe (instrumentation.ts, instrumentation-client.ts, app/global-error.tsx, next.config.ts wrapper, Dockerfile ARG declarations) is in \`vibn-frontend/lib/scaffold/sentry-snippets.ts\` — read it once via \`fs_*\` if you're unsure, then copy the snippets into the user's project verbatim. Skip Sentry for non-app projects (CLIs, library-only repos).
|
||||
|
||||
**Testing Auth & Protected Routes:** Do NOT attempt to verify signup flows or authenticated routes by making HTTP requests (e.g. \`curl\` or \`http_fetch\`) to the dev server yourself. The app is protected by NextAuth or similar session cookies which you do not have. Just write the code, start the dev server via \`dev_server_start\`, and provide the user the clickable \`previewUrl\` so they can test it themselves in their browser. If you hit a redirect/401, do NOT assume the server is broken and loop on restarting it.
|
||||
|
||||
**New public page → check the auth middleware allowlist (do this in the SAME turn):** If the project has an auth \`middleware.ts\`/\`middleware.js\` (NextAuth or similar) that redirects unknown routes to \`/login\`, then any brand-new **publicly viewable** page you add (e.g. \`/about\`, \`/contact\`, \`/pricing\`) will silently 307-redirect to login until its path is added to that middleware's public-route allowlist. So whenever you create a page that's meant to be public, open the middleware and add the route to the allowlist in the same turn — a page the user can't actually reach is NOT done. This is a single targeted \`fs_edit\`, not a verification loop or a curl/browser audit. (Do NOT do this for pages that are intentionally behind auth, like dashboards or account settings.)
|
||||
|
||||
**Design Critique / Visual QA Tool:**
|
||||
- \`request_visual_qa { targetPath }\` runs a fast background AI agent to critique a UI file (like \`page.tsx\`, \`layout.tsx\`, or \`.css\`) against a strict 5-dimensional design rubric (Layout, Spacing, Contrast, Hierarchy, Responsiveness).
|
||||
- You MUST call this tool whenever your turn involves creating or heavily modifying visual User Interface code before you return the \`previewUrl\` to the user.
|
||||
- If the tool returns a failure with actionable issues (e.g., "missing mobile padding" or "using hardcoded colors instead of CSS variables"), you MUST use \`fs_edit\` to fix those specific issues before ending your turn.
|
||||
- \`request_visual_qa { targetPath }\` runs a fast background AI agent to critique a UI file against a 5-dimensional design rubric (Layout, Spacing, Contrast, Hierarchy, Responsiveness).
|
||||
- Use it ONLY when you are **building a new page/component from scratch, or when the user explicitly asked for design/visual/polish work.** Do NOT use it for small, scoped edits (changing text, a color, a link, a single prop). Removing a word from a logo does not warrant a design audit.
|
||||
- Its critique is **ADVISORY**. If you run it, fix ONLY the issues that are directly caused by the change you just made or that the user asked about. **Do NOT fix pre-existing, unrelated critiques** (mobile menu layout, drop shadows, image optimization, breakpoints, etc.) — those are out of scope. Mention them in your reply instead and let the user decide.
|
||||
- Never let a QA critique turn a small edit into a rewrite. If you find yourself running QA more than once, or editing files the user didn't mention, STOP — you have left the task's scope.
|
||||
- Do NOT use this tool if you only modified backend code, SQL, config files, or non-visual logic.
|
||||
|
||||
**Rules:**
|
||||
@@ -806,10 +818,28 @@ export async function POST(request: Request) {
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
// Completely strip any legacy leaked "[tools executed this turn]" strings in case they exist in older messages
|
||||
.replace(/(?:\r?\n)*\[tools executed this turn:[\s\S]*?\]/g, "")
|
||||
// Strip legacy "### Phase Checkpoint" planning walls (Goal / Findings /
|
||||
// Suspected Cause / Verification Plan) from historical assistant
|
||||
// messages. That flow was removed, but old threads still contain it,
|
||||
// and replaying it as context biases the model into re-emitting the
|
||||
// same walls + verify-everything behavior. Drop from the heading to
|
||||
// the end of the message; any plain narration before it is kept.
|
||||
.replace(/(?:^|\n)\s*#{1,6}\s*Phase Checkpoint[\s\S]*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
return msg as unknown as ChatMessage;
|
||||
})
|
||||
// Drop assistant messages that became empty after stripping the internal
|
||||
// checkpoint/QA walls so they don't inject blank turns into the context.
|
||||
.filter((msg) => {
|
||||
if (msg.role !== "assistant") return true;
|
||||
const hasText =
|
||||
typeof msg.content === "string" && msg.content.trim().length > 0;
|
||||
const hasTools =
|
||||
Array.isArray((msg as { toolCalls?: unknown[] }).toolCalls) &&
|
||||
((msg as { toolCalls?: unknown[] }).toolCalls?.length ?? 0) > 0;
|
||||
return hasText || hasTools;
|
||||
});
|
||||
|
||||
// Add user message
|
||||
@@ -1031,16 +1061,6 @@ export async function POST(request: Request) {
|
||||
phase: chunk.phase,
|
||||
label: chunk.label,
|
||||
});
|
||||
} else if (
|
||||
chunk.type === "checkpoint" &&
|
||||
"goal" in chunk &&
|
||||
"findings" in chunk
|
||||
) {
|
||||
assistantTimeline.push({
|
||||
kind: "checkpoint",
|
||||
goal: chunk.goal,
|
||||
findings: chunk.findings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,13 +1122,6 @@ export async function POST(request: Request) {
|
||||
const turnIntent = classifyTurnIntent(message);
|
||||
const maxToolRounds = activeMcpToken ? TOOL_BUDGETS[turnIntent] : 0;
|
||||
let phase: AgentPhase = "recon";
|
||||
let checkpointEmitted = false;
|
||||
let verificationPassed = false;
|
||||
// When C-08 forces a "Phase Checkpoint" before a mutation, the model's
|
||||
// next reply is that internal planning block. We route it to the
|
||||
// (hidden) thinking channel instead of showing the user a wall of
|
||||
// Goal/Findings/Suspected-Cause text.
|
||||
let suppressNextTextAsCheckpoint = false;
|
||||
|
||||
// ── Server-side conversational guard (C-03 enforcement) ───────────
|
||||
// If the user's message looks conversational we withhold tools for
|
||||
@@ -1157,6 +1170,7 @@ export async function POST(request: Request) {
|
||||
tools: fixTools,
|
||||
temperature: 0.4,
|
||||
includeThoughts: true,
|
||||
signal: clientSignal,
|
||||
});
|
||||
if (r.text) {
|
||||
assistantText += (assistantText ? "\n\n" : "") + r.text;
|
||||
@@ -1180,6 +1194,7 @@ export async function POST(request: Request) {
|
||||
activeMcpToken,
|
||||
baseUrl,
|
||||
activeProject?.id,
|
||||
clientSignal,
|
||||
)
|
||||
: JSON.stringify({ error: "No MCP token" });
|
||||
emit({
|
||||
@@ -1247,52 +1262,38 @@ export async function POST(request: Request) {
|
||||
tools: toolDefs,
|
||||
temperature: 0.7,
|
||||
includeThoughts: true,
|
||||
signal: clientSignal,
|
||||
});
|
||||
|
||||
// C-08: Force Checkpoint Before Mutation
|
||||
// (Moved safely *after* callVibnChat so 'resp' is defined)
|
||||
// When the model first reaches for a mutation, advance the phase so
|
||||
// the UI reflects "Executing Code Edits". We deliberately do NOT force
|
||||
// a separate planning round or discard the edit (the old "C-08
|
||||
// checkpoint" dance) — that made the model plan, stall on an empty
|
||||
// turn, and never execute, and it seeded scope-creep via the forced
|
||||
// "verification plan". The agent edits directly; the post-loop
|
||||
// verification layer checks the result and drives any fixes.
|
||||
const requestedMutations = resp.toolCalls.filter((tc) =>
|
||||
[
|
||||
"fs_write",
|
||||
"fs_edit",
|
||||
"fs_delete",
|
||||
"dev_server_start",
|
||||
"dev_server_stop",
|
||||
"apps_deploy",
|
||||
"ship",
|
||||
].includes(tc.name),
|
||||
);
|
||||
|
||||
if (
|
||||
requestedMutations.length > 0 &&
|
||||
!checkpointEmitted &&
|
||||
phase === "recon"
|
||||
) {
|
||||
const blockMsg =
|
||||
"[PHASE CHECKPOINT REQUIRED] Before editing files or deploying, you MUST state your goal, current findings, the suspected cause of the issue, the exact file(s) to change, and your verification plan. Do not call any tools in your response.";
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: blockMsg,
|
||||
});
|
||||
emit({
|
||||
type: "checkpoint",
|
||||
goal: "Awaiting checkpoint...",
|
||||
findings: "Evaluating...",
|
||||
});
|
||||
checkpointEmitted = true;
|
||||
suppressNextTextAsCheckpoint = true;
|
||||
if (requestedMutations.length > 0 && phase === "recon") {
|
||||
phase = "execute";
|
||||
emit({ type: "phase", phase, label: "Executing Code Edits" });
|
||||
continue; // Skip tool execution and re-prompt
|
||||
}
|
||||
|
||||
if (requestedMutations.length > 0) {
|
||||
phase = "verify";
|
||||
emit({
|
||||
type: "phase",
|
||||
phase,
|
||||
label: "Verifying Build & Compiling",
|
||||
});
|
||||
// A Stop click aborts the in-flight generation, which surfaces here
|
||||
// as resp.error === "aborted". Treat it as a clean user stop (break to
|
||||
// the post-loop abort handling that persists the partial reply),
|
||||
// NOT as a fatal error shown to the user.
|
||||
if (resp.error === "aborted" || aborted) {
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (resp.error) {
|
||||
@@ -1302,13 +1303,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Stream user-facing text to client.
|
||||
// If this round's text is the forced Phase Checkpoint, route it to
|
||||
// the hidden thinking channel and DON'T add it to the user-facing
|
||||
// message (so it never shows live or in the persisted thread).
|
||||
if (resp.text && suppressNextTextAsCheckpoint) {
|
||||
emit({ type: "thinking", text: resp.text });
|
||||
suppressNextTextAsCheckpoint = false;
|
||||
} else if (resp.text) {
|
||||
if (resp.text) {
|
||||
assistantText += (assistantText ? "\n\n" : "") + resp.text;
|
||||
assistantTextSegments.push(resp.text);
|
||||
emit({ type: "text", text: resp.text });
|
||||
@@ -1371,6 +1366,7 @@ export async function POST(request: Request) {
|
||||
activeMcpToken,
|
||||
baseUrl,
|
||||
activeProject?.id,
|
||||
clientSignal,
|
||||
)
|
||||
: Promise.resolve(
|
||||
JSON.stringify({ error: "No MCP token — read-only mode." }),
|
||||
@@ -1558,6 +1554,7 @@ export async function POST(request: Request) {
|
||||
activeMcpToken,
|
||||
baseUrl,
|
||||
activeProject!.id,
|
||||
clientSignal,
|
||||
);
|
||||
const vTask: VerificationTask = {
|
||||
id: thread_id,
|
||||
@@ -1638,6 +1635,7 @@ export async function POST(request: Request) {
|
||||
messages,
|
||||
tools: [],
|
||||
temperature: 0.3,
|
||||
signal: clientSignal,
|
||||
});
|
||||
if (summary.text && summary.text.trim()) {
|
||||
assistantText += (assistantText ? "\n\n" : "") + summary.text;
|
||||
@@ -1692,6 +1690,7 @@ export async function POST(request: Request) {
|
||||
messages,
|
||||
tools: [],
|
||||
temperature: 0.3,
|
||||
signal: clientSignal,
|
||||
});
|
||||
if (finalSummary.text && finalSummary.text.trim()) {
|
||||
assistantText +=
|
||||
|
||||
@@ -71,6 +71,7 @@ interface ProductImage {
|
||||
|
||||
interface BuildSummary {
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
commit?: string;
|
||||
}
|
||||
@@ -112,7 +113,7 @@ interface Preview {
|
||||
interface InfraDatabase {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse
|
||||
type: string; // postgresql / redis / mongodb / mysql / keydb / clickhouse
|
||||
status: string;
|
||||
isPublic: boolean;
|
||||
publicPort?: number;
|
||||
@@ -130,13 +131,13 @@ interface InfraProvider {
|
||||
/** Stable id used by the UI for selection */
|
||||
id: string;
|
||||
category: "auth" | "email" | "payments" | "llm" | "storage";
|
||||
vendor: string; // "Stripe", "Resend", "OpenAI", …
|
||||
vendor: string; // "Stripe", "Resend", "OpenAI", …
|
||||
/** Where the env keys for this provider live */
|
||||
attachments: Array<{
|
||||
resourceUuid: string;
|
||||
resourceName: string;
|
||||
resourceKind: "app" | "service";
|
||||
keys: string[]; // matching env var keys (values redacted)
|
||||
keys: string[]; // matching env var keys (values redacted)
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -170,7 +171,12 @@ interface InfraSecretSummary {
|
||||
}
|
||||
|
||||
interface Anatomy {
|
||||
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
gitea?: string;
|
||||
coolifyProjectUuid?: string;
|
||||
};
|
||||
codebasesReason?: "no_repo" | "empty_repo";
|
||||
product: {
|
||||
codebases: Codebase[];
|
||||
@@ -198,14 +204,17 @@ interface GiteaItem {
|
||||
type: "file" | "dir" | "symlink";
|
||||
}
|
||||
|
||||
async function giteaList(repo: string, path: string): Promise<GiteaItem[] | null> {
|
||||
async function giteaList(
|
||||
repo: string,
|
||||
path: string,
|
||||
): Promise<GiteaItem[] | null> {
|
||||
const encoded = path ? encodeURIComponent(path).replace(/%2F/g, "/") : "";
|
||||
const res = await fetch(
|
||||
`${GITEA_API_URL}/api/v1/repos/${repo}/contents/${encoded}`,
|
||||
{
|
||||
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
|
||||
next: { revalidate: 30 },
|
||||
}
|
||||
},
|
||||
);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Gitea ${res.status} listing ${repo}/${path}`);
|
||||
@@ -220,15 +229,21 @@ async function discoverCodebases(giteaRepo: string): Promise<{
|
||||
const root = await giteaList(giteaRepo, "");
|
||||
if (!root) return { codebases: [], reason: "empty_repo" };
|
||||
|
||||
const appsDir = root.find(item => item.type === "dir" && item.name === "apps");
|
||||
const appsDir = root.find(
|
||||
(item) => item.type === "dir" && item.name === "apps",
|
||||
);
|
||||
let codebases: Codebase[] = [];
|
||||
|
||||
if (appsDir) {
|
||||
const appsChildren = await giteaList(giteaRepo, "apps");
|
||||
if (appsChildren) {
|
||||
codebases = appsChildren
|
||||
.filter(item => item.type === "dir")
|
||||
.map(item => ({ id: item.name, label: item.name, path: `apps/${item.name}` }));
|
||||
.filter((item) => item.type === "dir")
|
||||
.map((item) => ({
|
||||
id: item.name,
|
||||
label: item.name,
|
||||
path: `apps/${item.name}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,21 +284,27 @@ function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
|
||||
const target = giteaRepo.toLowerCase();
|
||||
const appShort = shortFormOfRepo(app.git_repository);
|
||||
if (appShort && appShort === target) return true;
|
||||
return Boolean(app.git_repository && app.git_repository.toLowerCase().includes(target));
|
||||
return Boolean(
|
||||
app.git_repository && app.git_repository.toLowerCase().includes(target),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRepoApps(giteaRepo: string | undefined): Promise<CoolifyApplication[]> {
|
||||
async function loadRepoApps(
|
||||
giteaRepo: string | undefined,
|
||||
): Promise<CoolifyApplication[]> {
|
||||
if (!giteaRepo) return [];
|
||||
try {
|
||||
const all = await listApplications();
|
||||
return all.filter(app => appMatchesRepo(app, giteaRepo));
|
||||
return all.filter((app) => appMatchesRepo(app, giteaRepo));
|
||||
} catch (err) {
|
||||
console.error("[anatomy] listApplications failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
|
||||
async function loadProjectServices(
|
||||
coolifyProjectUuid: string | undefined,
|
||||
): Promise<CoolifyService[]> {
|
||||
if (!coolifyProjectUuid) return [];
|
||||
try {
|
||||
return await listServicesInProject(coolifyProjectUuid);
|
||||
@@ -304,7 +325,12 @@ async function loadProjectServices(coolifyProjectUuid: string | undefined): Prom
|
||||
// apps so the project header pill reflects what the user actually
|
||||
// cares about. This costs N extra Coolify GETs per anatomy call (one
|
||||
// per image service) — small N in practice, parallelised below.
|
||||
interface ServiceAppStatus { name: string; status?: string; exclude_from_status?: boolean; fqdn?: string }
|
||||
interface ServiceAppStatus {
|
||||
name: string;
|
||||
status?: string;
|
||||
exclude_from_status?: boolean;
|
||||
fqdn?: string;
|
||||
}
|
||||
async function smartServiceMetaFor(
|
||||
uuid: string,
|
||||
fallbackStatus: string | undefined,
|
||||
@@ -313,9 +339,16 @@ async function smartServiceMetaFor(
|
||||
try {
|
||||
const detail = await fetch(
|
||||
`${process.env.COOLIFY_URL}/api/v1/services/${uuid}`,
|
||||
{ headers: { Authorization: `Bearer ${process.env.COOLIFY_API_TOKEN}` }, cache: "no-store" },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${process.env.COOLIFY_API_TOKEN}` },
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!detail.ok) return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn) };
|
||||
if (!detail.ok)
|
||||
return {
|
||||
status: fallbackStatus ?? "unknown",
|
||||
fqdns: fqdnsOf(serviceLevelFqdn),
|
||||
};
|
||||
const body = (await detail.json()) as { applications?: ServiceAppStatus[] };
|
||||
const apps = body.applications ?? [];
|
||||
const userFacing = apps.filter((a) => !a.exclude_from_status);
|
||||
@@ -325,10 +358,12 @@ async function smartServiceMetaFor(
|
||||
// application (e.g. Twenty's `twenty` app). Prefer fqdns from
|
||||
// user-facing apps; fall back to the service-level value.
|
||||
const harvested: string[] = [];
|
||||
for (const a of userFacing) for (const f of fqdnsOf(a.fqdn)) harvested.push(f);
|
||||
for (const a of userFacing)
|
||||
for (const f of fqdnsOf(a.fqdn)) harvested.push(f);
|
||||
const fqdns = harvested.length > 0 ? harvested : fqdnsOf(serviceLevelFqdn);
|
||||
|
||||
if (userFacing.length === 0) return { status: fallbackStatus ?? "unknown", fqdns };
|
||||
if (userFacing.length === 0)
|
||||
return { status: fallbackStatus ?? "unknown", fqdns };
|
||||
|
||||
const ranked = userFacing.map((a) => rankServiceAppStatus(a.status));
|
||||
let status: string;
|
||||
@@ -340,24 +375,40 @@ async function smartServiceMetaFor(
|
||||
return { status, fqdns };
|
||||
} catch (err) {
|
||||
console.error(`[anatomy] smartServiceMetaFor(${uuid}) failed:`, err);
|
||||
return { status: fallbackStatus ?? "unknown", fqdns: fqdnsOf(serviceLevelFqdn) };
|
||||
return {
|
||||
status: fallbackStatus ?? "unknown",
|
||||
fqdns: fqdnsOf(serviceLevelFqdn),
|
||||
};
|
||||
}
|
||||
}
|
||||
function rankServiceAppStatus(s?: string): "up" | "transient" | "down" | "unknown" {
|
||||
function rankServiceAppStatus(
|
||||
s?: string,
|
||||
): "up" | "transient" | "down" | "unknown" {
|
||||
const v = (s ?? "").toLowerCase();
|
||||
if (!v) return "unknown";
|
||||
if (/^(running|healthy)/.test(v)) return "up";
|
||||
if (/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(v)) return "transient";
|
||||
if (
|
||||
/^(starting|restarting|created|paused|deploying|building|in_progress|queued)/.test(
|
||||
v,
|
||||
)
|
||||
)
|
||||
return "transient";
|
||||
if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(v)) return "down";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-");
|
||||
const isDevContainer = (svc: CoolifyService) =>
|
||||
svc.name.startsWith("vibn-dev-");
|
||||
|
||||
/** Extract image:version from a Coolify docker_compose_raw blob.
|
||||
* Best-effort regex; we only want a sensible label, not perfection. */
|
||||
function extractImageInfo(svc: CoolifyService): { image: string; version: string } {
|
||||
const raw = (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ?? "";
|
||||
function extractImageInfo(svc: CoolifyService): {
|
||||
image: string;
|
||||
version: string;
|
||||
} {
|
||||
const raw =
|
||||
(svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ??
|
||||
"";
|
||||
const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
|
||||
if (!m) return { image: svc.service_type ?? svc.name, version: "" };
|
||||
const full = m[1];
|
||||
@@ -372,18 +423,25 @@ function fqdnsOf(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map(s => {
|
||||
.map((s) => {
|
||||
const trimmed = s.trim().replace(/\/$/, "");
|
||||
if (!trimmed) return "";
|
||||
// Strip default ports that won't work through a public reverse
|
||||
// proxy (https on 443, http on 80, and Coolify's internal :3000
|
||||
// which Traefik bridges to 443 anyway).
|
||||
try {
|
||||
const u = new URL(/^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`);
|
||||
const u = new URL(
|
||||
/^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`,
|
||||
);
|
||||
const host = u.hostname;
|
||||
const port = u.port;
|
||||
const isHttps = u.protocol === "https:";
|
||||
const showPort = port && !((isHttps && (port === "443" || port === "3000")) || (!isHttps && port === "80"));
|
||||
const showPort =
|
||||
port &&
|
||||
!(
|
||||
(isHttps && (port === "443" || port === "3000")) ||
|
||||
(!isHttps && port === "80")
|
||||
);
|
||||
return showPort ? `${host}:${port}` : host;
|
||||
} catch {
|
||||
return trimmed.replace(/^https?:\/\//, "");
|
||||
@@ -397,7 +455,8 @@ function fqdnsOf(value: string | undefined): string[] {
|
||||
// configured a real domain, that's what we want to surface in the
|
||||
// header — push sslip.io URLs to the back.
|
||||
function prioritiseFqdns(fqdns: string[]): string[] {
|
||||
const isAuto = (f: string) => /\.sslip\.io(?::|$)/i.test(f) || /\.coolify\.app(?::|$)/i.test(f);
|
||||
const isAuto = (f: string) =>
|
||||
/\.sslip\.io(?::|$)/i.test(f) || /\.coolify\.app(?::|$)/i.test(f);
|
||||
return [...fqdns].sort((a, b) => Number(isAuto(a)) - Number(isAuto(b)));
|
||||
}
|
||||
|
||||
@@ -405,7 +464,7 @@ async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
|
||||
try {
|
||||
const deployments = await listApplicationDeployments(uuid);
|
||||
if (!deployments.length) return undefined;
|
||||
const finished = deployments.find(d => d.finished_at) ?? deployments[0];
|
||||
const finished = deployments.find((d) => d.finished_at) ?? deployments[0];
|
||||
return {
|
||||
status: finished.status,
|
||||
finishedAt: finished.finished_at,
|
||||
@@ -429,16 +488,30 @@ async function deploymentActivityFor(uuid: string): Promise<{
|
||||
const deployments = await listApplicationDeployments(uuid);
|
||||
if (!deployments.length) return {};
|
||||
|
||||
const isFinished = (s: string) => /^(success|finished|failed|cancelled|error|exited)$/i.test(s);
|
||||
const inFlightDep = deployments.find(d => !d.finished_at && !isFinished(d.status ?? ""));
|
||||
const finishedDep = deployments.find(d => d.finished_at) ?? deployments[0];
|
||||
const isFinished = (s: string) =>
|
||||
/^(success|finished|failed|cancelled|error|exited)$/i.test(s);
|
||||
const inFlightDep = deployments.find(
|
||||
(d) => !d.finished_at && !isFinished(d.status ?? ""),
|
||||
);
|
||||
const finishedDep =
|
||||
deployments.find((d) => d.finished_at) ?? deployments[0];
|
||||
|
||||
return {
|
||||
lastBuild: finishedDep
|
||||
? { status: finishedDep.status, finishedAt: finishedDep.finished_at, commit: finishedDep.commit }
|
||||
? {
|
||||
status: finishedDep.status,
|
||||
createdAt: finishedDep.created_at,
|
||||
finishedAt: finishedDep.finished_at,
|
||||
commit: finishedDep.commit,
|
||||
}
|
||||
: undefined,
|
||||
inFlight: inFlightDep
|
||||
? { status: inFlightDep.status, finishedAt: inFlightDep.finished_at, commit: inFlightDep.commit }
|
||||
? {
|
||||
status: inFlightDep.status,
|
||||
createdAt: inFlightDep.created_at,
|
||||
finishedAt: inFlightDep.finished_at,
|
||||
commit: inFlightDep.commit,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -465,7 +538,9 @@ function dbTypeOf(d: CoolifyDatabase): string {
|
||||
}
|
||||
|
||||
/** Best-effort host:port from the in-cluster URL (creds stripped). */
|
||||
function parseInternalAddress(internalUrl: string | undefined): string | undefined {
|
||||
function parseInternalAddress(
|
||||
internalUrl: string | undefined,
|
||||
): string | undefined {
|
||||
if (!internalUrl) return undefined;
|
||||
try {
|
||||
const u = new URL(internalUrl);
|
||||
@@ -478,17 +553,20 @@ function parseInternalAddress(internalUrl: string | undefined): string | undefin
|
||||
}
|
||||
|
||||
function consumerKeyFor(type: string): string {
|
||||
if (type === "redis" || type === "keydb" || type === "dragonfly") return "REDIS_URL";
|
||||
if (type === "redis" || type === "keydb" || type === "dragonfly")
|
||||
return "REDIS_URL";
|
||||
if (type === "mongodb") return "MONGODB_URI";
|
||||
if (type === "clickhouse") return "CLICKHOUSE_URL";
|
||||
return "DATABASE_URL";
|
||||
}
|
||||
|
||||
async function loadDatabases(coolifyProjectUuid: string | undefined): Promise<InfraDatabase[]> {
|
||||
async function loadDatabases(
|
||||
coolifyProjectUuid: string | undefined,
|
||||
): Promise<InfraDatabase[]> {
|
||||
if (!coolifyProjectUuid) return [];
|
||||
try {
|
||||
const dbs = await listDatabasesInProject(coolifyProjectUuid);
|
||||
return dbs.map(d => {
|
||||
return dbs.map((d) => {
|
||||
const type = dbTypeOf(d);
|
||||
return {
|
||||
uuid: d.uuid,
|
||||
@@ -529,32 +607,52 @@ const PROVIDER_RULES: Array<{
|
||||
pattern: RegExp;
|
||||
}> = [
|
||||
// Auth
|
||||
{ category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
|
||||
{ category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
|
||||
{ category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
|
||||
{ category: "auth", vendor: "Supabase Auth",pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/ },
|
||||
{ category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
|
||||
{ category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
|
||||
{ category: "auth", vendor: "Firebase Auth",pattern: /^FIREBASE_(AUTH|API_KEY)/ },
|
||||
{ category: "auth", vendor: "Clerk", pattern: /^(NEXT_PUBLIC_)?CLERK_/ },
|
||||
{ category: "auth", vendor: "NextAuth", pattern: /^NEXTAUTH_/ },
|
||||
{ category: "auth", vendor: "Auth0", pattern: /^AUTH0_/ },
|
||||
{
|
||||
category: "auth",
|
||||
vendor: "Supabase Auth",
|
||||
pattern: /^SUPABASE_(SERVICE_ROLE|JWT|ANON)/,
|
||||
},
|
||||
{ category: "auth", vendor: "SuperTokens", pattern: /^SUPERTOKENS_/ },
|
||||
{ category: "auth", vendor: "WorkOS", pattern: /^WORKOS_/ },
|
||||
{
|
||||
category: "auth",
|
||||
vendor: "Firebase Auth",
|
||||
pattern: /^FIREBASE_(AUTH|API_KEY)/,
|
||||
},
|
||||
// Email
|
||||
{ category: "email", vendor: "Resend", pattern: /^RESEND_/ },
|
||||
{ category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
|
||||
{ category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
|
||||
{ category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
|
||||
{ category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
|
||||
{ category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
|
||||
{ category: "email", vendor: "Resend", pattern: /^RESEND_/ },
|
||||
{ category: "email", vendor: "Mailgun", pattern: /^MAILGUN_/ },
|
||||
{ category: "email", vendor: "Postmark", pattern: /^POSTMARK_/ },
|
||||
{ category: "email", vendor: "SendGrid", pattern: /^SENDGRID_/ },
|
||||
{ category: "email", vendor: "AWS SES", pattern: /^(SES_|AWS_SES_)/ },
|
||||
{ category: "email", vendor: "Loops", pattern: /^LOOPS_/ },
|
||||
// Payments
|
||||
{ category: "payments", vendor: "Stripe", pattern: /^(NEXT_PUBLIC_)?STRIPE_/ },
|
||||
{ category: "payments", vendor: "LemonSqueezy", pattern: /^LEMON(SQUEEZY)?_/ },
|
||||
{ category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
|
||||
{
|
||||
category: "payments",
|
||||
vendor: "Stripe",
|
||||
pattern: /^(NEXT_PUBLIC_)?STRIPE_/,
|
||||
},
|
||||
{
|
||||
category: "payments",
|
||||
vendor: "LemonSqueezy",
|
||||
pattern: /^LEMON(SQUEEZY)?_/,
|
||||
},
|
||||
{ category: "payments", vendor: "Paddle", pattern: /^PADDLE_/ },
|
||||
// LLM (a.k.a. Models)
|
||||
{ category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
|
||||
{ category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
|
||||
{ category: "llm", vendor: "Google AI", pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/ },
|
||||
{ category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
|
||||
{ category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
|
||||
{ category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
|
||||
{ category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
|
||||
{ category: "llm", vendor: "OpenAI", pattern: /^OPENAI_/ },
|
||||
{ category: "llm", vendor: "Anthropic", pattern: /^ANTHROPIC_/ },
|
||||
{
|
||||
category: "llm",
|
||||
vendor: "Google AI",
|
||||
pattern: /^(GEMINI_|GOOGLE_AI_|GOOGLE_GENAI_)/,
|
||||
},
|
||||
{ category: "llm", vendor: "Mistral", pattern: /^MISTRAL_/ },
|
||||
{ category: "llm", vendor: "Cohere", pattern: /^COHERE_/ },
|
||||
{ category: "llm", vendor: "Groq", pattern: /^GROQ_/ },
|
||||
{ category: "llm", vendor: "OpenRouter", pattern: /^OPENROUTER_/ },
|
||||
];
|
||||
|
||||
interface ResourceEnvs {
|
||||
@@ -566,7 +664,7 @@ interface ResourceEnvs {
|
||||
|
||||
async function loadAllEnvs(
|
||||
apps: CoolifyApplication[],
|
||||
services: CoolifyService[]
|
||||
services: CoolifyService[],
|
||||
): Promise<ResourceEnvs[]> {
|
||||
const appPromises = apps.map(async (a): Promise<ResourceEnvs> => {
|
||||
try {
|
||||
@@ -575,11 +673,16 @@ async function loadAllEnvs(
|
||||
resourceUuid: a.uuid,
|
||||
resourceName: a.name,
|
||||
resourceKind: "app",
|
||||
keys: envs.map(e => e.key),
|
||||
keys: envs.map((e) => e.key),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[anatomy] listApplicationEnvs(${a.uuid}) failed:`, err);
|
||||
return { resourceUuid: a.uuid, resourceName: a.name, resourceKind: "app", keys: [] };
|
||||
return {
|
||||
resourceUuid: a.uuid,
|
||||
resourceName: a.name,
|
||||
resourceKind: "app",
|
||||
keys: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
const svcPromises = services.map(async (s): Promise<ResourceEnvs> => {
|
||||
@@ -589,11 +692,16 @@ async function loadAllEnvs(
|
||||
resourceUuid: s.uuid,
|
||||
resourceName: s.name,
|
||||
resourceKind: "service",
|
||||
keys: envs.map(e => e.key),
|
||||
keys: envs.map((e) => e.key),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[anatomy] listServiceEnvs(${s.uuid}) failed:`, err);
|
||||
return { resourceUuid: s.uuid, resourceName: s.name, resourceKind: "service", keys: [] };
|
||||
return {
|
||||
resourceUuid: s.uuid,
|
||||
resourceName: s.name,
|
||||
resourceKind: "service",
|
||||
keys: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
return Promise.all([...appPromises, ...svcPromises]);
|
||||
@@ -606,12 +714,17 @@ function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] {
|
||||
for (const env of allEnvs) {
|
||||
if (env.keys.length === 0) continue;
|
||||
for (const rule of PROVIDER_RULES) {
|
||||
const matches = env.keys.filter(k => rule.pattern.test(k));
|
||||
const matches = env.keys.filter((k) => rule.pattern.test(k));
|
||||
if (matches.length === 0) continue;
|
||||
const id = `${rule.category}:${rule.vendor.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
let entry = byVendor.get(id);
|
||||
if (!entry) {
|
||||
entry = { id, category: rule.category, vendor: rule.vendor, attachments: [] };
|
||||
entry = {
|
||||
id,
|
||||
category: rule.category,
|
||||
vendor: rule.vendor,
|
||||
attachments: [],
|
||||
};
|
||||
byVendor.set(id, entry);
|
||||
}
|
||||
entry.attachments.push({
|
||||
@@ -627,8 +740,8 @@ function detectProviders(allEnvs: ResourceEnvs[]): InfraProvider[] {
|
||||
|
||||
function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary {
|
||||
const byResource = allEnvs
|
||||
.filter(e => e.keys.length > 0)
|
||||
.map(e => ({
|
||||
.filter((e) => e.keys.length > 0)
|
||||
.map((e) => ({
|
||||
resourceUuid: e.resourceUuid,
|
||||
resourceName: e.resourceName,
|
||||
resourceKind: e.resourceKind,
|
||||
@@ -640,21 +753,26 @@ function summariseSecrets(allEnvs: ResourceEnvs[]): InfraSecretSummary {
|
||||
return { total, byResource };
|
||||
}
|
||||
|
||||
async function loadBundledStorage(workspaceId: string | undefined): Promise<BundledStorage> {
|
||||
async function loadBundledStorage(
|
||||
workspaceId: string | undefined,
|
||||
): Promise<BundledStorage> {
|
||||
if (!workspaceId) return { status: "unprovisioned" };
|
||||
try {
|
||||
const ws = await getWorkspaceGcsState(workspaceId);
|
||||
if (!ws) return { status: "unprovisioned" };
|
||||
return {
|
||||
status: ws.gcp_provision_status ?? "unprovisioned",
|
||||
bucketName: ws.gcs_default_bucket_name ?? undefined,
|
||||
hmacAccessId: ws.gcs_hmac_access_id ?? undefined,
|
||||
region: VIBN_GCS_LOCATION,
|
||||
errorMessage: ws.gcp_provision_error ?? undefined,
|
||||
bucketName: ws.gcs_default_bucket_name ?? undefined,
|
||||
hmacAccessId: ws.gcs_hmac_access_id ?? undefined,
|
||||
region: VIBN_GCS_LOCATION,
|
||||
errorMessage: ws.gcp_provision_error ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[anatomy] getWorkspaceGcsState failed:", err);
|
||||
return { status: "error", errorMessage: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,9 +790,9 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
|
||||
`SELECT id, name, command, port, preview_url, state, started_at
|
||||
FROM fs_dev_servers
|
||||
WHERE project_id = $1 AND state != 'stopped'`,
|
||||
[projectId]
|
||||
[projectId],
|
||||
);
|
||||
return sortDevPreviewsFrontendFirst(rows).map(r => ({
|
||||
return sortDevPreviewsFrontendFirst(rows).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
command: r.command ?? undefined,
|
||||
@@ -684,7 +802,10 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
|
||||
startedAt: r.started_at,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && /relation "fs_dev_servers" does not exist/i.test(err.message)) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
/relation "fs_dev_servers" does not exist/i.test(err.message)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
console.error("[anatomy] fs_dev_servers query failed:", err);
|
||||
@@ -698,7 +819,7 @@ async function loadPreviews(projectId: string): Promise<Preview[]> {
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
@@ -707,11 +828,14 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rows = await query<{ data: Record<string, unknown>; vibn_workspace_id: string | null }>(
|
||||
const rows = await query<{
|
||||
data: Record<string, unknown>;
|
||||
vibn_workspace_id: string | null;
|
||||
}>(
|
||||
`SELECT p.data, p.vibn_workspace_id FROM fs_projects p
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||
[projectId, session.user.email]
|
||||
[projectId, session.user.email],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
@@ -728,22 +852,31 @@ export async function GET(
|
||||
// and re-provision a dedicated Coolify project on the fly.
|
||||
if (coolifyProjectUuid && workspaceId) {
|
||||
try {
|
||||
const wsRow = await query<{ slug: string; coolify_project_uuid: string | null }>(
|
||||
const wsRow = await query<{
|
||||
slug: string;
|
||||
coolify_project_uuid: string | null;
|
||||
}>(
|
||||
`SELECT slug, coolify_project_uuid FROM vibn_workspaces WHERE id = $1 LIMIT 1`,
|
||||
[workspaceId],
|
||||
);
|
||||
const ws = wsRow[0];
|
||||
if (ws && ws.coolify_project_uuid && ws.coolify_project_uuid === coolifyProjectUuid) {
|
||||
if (
|
||||
ws &&
|
||||
ws.coolify_project_uuid &&
|
||||
ws.coolify_project_uuid === coolifyProjectUuid
|
||||
) {
|
||||
console.warn(
|
||||
"[anatomy] Project", projectId,
|
||||
"had workspace-UUID stored as coolifyProjectUuid — re-provisioning."
|
||||
"[anatomy] Project",
|
||||
projectId,
|
||||
"had workspace-UUID stored as coolifyProjectUuid — re-provisioning.",
|
||||
);
|
||||
const projectSlug = (data?.slug as string | undefined) ?? projectId;
|
||||
const projectNm = (data?.productName as string | undefined) ?? projectSlug;
|
||||
const projectNm =
|
||||
(data?.productName as string | undefined) ?? projectSlug;
|
||||
const wantName = `vibn-${ws.slug}-${projectSlug}`;
|
||||
try {
|
||||
const all = await listCoolifyProjects();
|
||||
const existing = all.find(p => p.name === wantName);
|
||||
const existing = all.find((p) => p.name === wantName);
|
||||
const fresh = existing
|
||||
? existing
|
||||
: await createCoolifyProject(
|
||||
@@ -764,7 +897,10 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
} catch (wsErr) {
|
||||
console.error("[anatomy] workspace lookup for self-heal failed:", wsErr);
|
||||
console.error(
|
||||
"[anatomy] workspace lookup for self-heal failed:",
|
||||
wsErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectName =
|
||||
@@ -772,13 +908,26 @@ export async function GET(
|
||||
(data?.name as string | undefined) ??
|
||||
"Project";
|
||||
|
||||
const [codebasesResult, repoApps, allServices, previews, databases, bundledStorage] = await Promise.all([
|
||||
const [
|
||||
codebasesResult,
|
||||
repoApps,
|
||||
allServices,
|
||||
previews,
|
||||
databases,
|
||||
bundledStorage,
|
||||
] = await Promise.all([
|
||||
giteaRepo
|
||||
? discoverCodebases(giteaRepo).catch(err => {
|
||||
? discoverCodebases(giteaRepo).catch((err) => {
|
||||
console.error("[anatomy] discoverCodebases failed:", err);
|
||||
return { codebases: [] as Codebase[], reason: "empty_repo" as const };
|
||||
return {
|
||||
codebases: [] as Codebase[],
|
||||
reason: "empty_repo" as const,
|
||||
};
|
||||
})
|
||||
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
|
||||
: Promise.resolve({
|
||||
codebases: [] as Codebase[],
|
||||
reason: undefined as undefined,
|
||||
}),
|
||||
loadRepoApps(giteaRepo),
|
||||
loadProjectServices(coolifyProjectUuid),
|
||||
loadPreviews(projectId),
|
||||
@@ -790,14 +939,17 @@ export async function GET(
|
||||
// parallel (small N). In parallel, fan out env-var fetches to drive
|
||||
// provider/secret detection.
|
||||
const [activities, allEnvs] = await Promise.all([
|
||||
Promise.all(repoApps.map(a => deploymentActivityFor(a.uuid))),
|
||||
loadAllEnvs(repoApps, allServices.filter(s => !isDevContainer(s))),
|
||||
Promise.all(repoApps.map((a) => deploymentActivityFor(a.uuid))),
|
||||
loadAllEnvs(
|
||||
repoApps,
|
||||
allServices.filter((s) => !isDevContainer(s)),
|
||||
),
|
||||
]);
|
||||
|
||||
// Image services (Coolify services minus vibn-dev-*)
|
||||
const imageServices = allServices.filter(s => !isDevContainer(s));
|
||||
const imageServices = allServices.filter((s) => !isDevContainer(s));
|
||||
|
||||
const productImages: ProductImage[] = imageServices.map(s => {
|
||||
const productImages: ProductImage[] = imageServices.map((s) => {
|
||||
const { image, version } = extractImageInfo(s);
|
||||
return {
|
||||
uuid: s.uuid,
|
||||
@@ -815,7 +967,8 @@ export async function GET(
|
||||
uuid: app.uuid,
|
||||
name: app.name,
|
||||
source: "repo",
|
||||
sourceLabel: shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
|
||||
sourceLabel:
|
||||
shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
|
||||
status: app.status,
|
||||
fqdn: domains[0],
|
||||
domains,
|
||||
@@ -830,7 +983,11 @@ export async function GET(
|
||||
// every image service in parallel — see smartServiceMetaFor.
|
||||
const smartMeta = await Promise.all(
|
||||
imageServices.map((s) =>
|
||||
smartServiceMetaFor(s.uuid, s.status, (s as unknown as { fqdn?: string }).fqdn),
|
||||
smartServiceMetaFor(
|
||||
s.uuid,
|
||||
s.status,
|
||||
(s as unknown as { fqdn?: string }).fqdn,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -853,7 +1010,12 @@ export async function GET(
|
||||
: codebasesResult.reason;
|
||||
|
||||
const anatomy: Anatomy = {
|
||||
project: { id: projectId, name: projectName, gitea: giteaRepo, coolifyProjectUuid },
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectName,
|
||||
gitea: giteaRepo,
|
||||
coolifyProjectUuid,
|
||||
},
|
||||
codebasesReason,
|
||||
product: {
|
||||
codebases: codebasesResult.codebases,
|
||||
@@ -874,6 +1036,9 @@ export async function GET(
|
||||
return NextResponse.json(anatomy);
|
||||
} catch (err) {
|
||||
console.error("[anatomy API]", err);
|
||||
return NextResponse.json({ error: "Failed to build anatomy" }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to build anatomy" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,18 @@ export interface Anatomy {
|
||||
domains: string[];
|
||||
branch?: string;
|
||||
buildPack?: string;
|
||||
lastBuild?: { status: string; finishedAt?: string; commit?: string };
|
||||
inFlightBuild?: { status: string; finishedAt?: string; commit?: string };
|
||||
lastBuild?: {
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
commit?: string;
|
||||
};
|
||||
inFlightBuild?: {
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
commit?: string;
|
||||
};
|
||||
}>;
|
||||
previews: Array<{
|
||||
id: string;
|
||||
|
||||
@@ -23,9 +23,6 @@ import {
|
||||
Trash2,
|
||||
Square,
|
||||
MousePointerClick,
|
||||
Sparkles,
|
||||
Compass,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { ProjectIconRail } from "@/components/project/project-icon-rail";
|
||||
@@ -67,7 +64,6 @@ interface Message {
|
||||
type TimelineEntry =
|
||||
| { kind: "thought"; text: string }
|
||||
| { kind: "phase"; phase: string; label: string }
|
||||
| { kind: "checkpoint"; goal: string; findings: string }
|
||||
| {
|
||||
kind: "tool";
|
||||
name: string;
|
||||
@@ -467,87 +463,13 @@ function renderMarkdown(text: string): string {
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Message bubble ────────────────────────────────────────────────────────────
|
||||
// ── Message bubble ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ThinkingBubble({ thoughts }: { thoughts: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
if (!thoughts) return null;
|
||||
|
||||
// Split thoughts into phrases, take the last one as the "current" action
|
||||
const lines = thoughts
|
||||
.split(/[.!?\n]/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const currentAction = lines[lines.length - 1];
|
||||
|
||||
if (!currentAction) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: "4px 0",
|
||||
background: "#faf8f5",
|
||||
border: "1px dashed #e8e4dc",
|
||||
borderRadius: 8,
|
||||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
gap: 8,
|
||||
padding: "6px 12px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: "0.75rem",
|
||||
color: "#8c8580",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 14, display: "flex", justifyContent: "center" }}>
|
||||
<Sparkles
|
||||
style={{ width: 12, height: 12, color: "#d4a04a" }}
|
||||
className="animate-pulse"
|
||||
/>
|
||||
</span>
|
||||
<span style={{ flex: 1, fontStyle: "italic" }}>
|
||||
{expanded ? "Thinking Process" : `${currentAction}...`}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
transform: expanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<ChevronDown style={{ width: 12, height: 12 }} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 12px 10px 34px",
|
||||
fontSize: "0.74rem",
|
||||
color: "#6b6560",
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: "pre-wrap",
|
||||
borderTop: "1px solid #f0ede8",
|
||||
marginTop: 4,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
{thoughts}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// NOTE: the model's raw reasoning ("thinking") is intentionally NOT rendered in
|
||||
// the chat — `thought` timeline entries are dropped at render time. We keep the
|
||||
// per-round narration (the model's plain "I am editing X…" text), which is
|
||||
// shown as normal text. The old collapsible ThinkingBubble was removed when we
|
||||
// stopped surfacing reasoning walls.
|
||||
|
||||
function stripRawToolLogs(text: string): string {
|
||||
if (!text) return text;
|
||||
@@ -683,7 +605,6 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
|
||||
| { kind: "thought"; text: string }
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "phase"; phase: string; label: string }
|
||||
| { kind: "checkpoint"; goal: string; findings: string }
|
||||
| {
|
||||
kind: "toolGroup";
|
||||
category: string;
|
||||
@@ -697,8 +618,6 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
|
||||
items.push({ kind: "text", text: e.text });
|
||||
} else if (e.kind === "phase") {
|
||||
items.push({ kind: "phase", phase: e.phase, label: e.label });
|
||||
} else if (e.kind === "checkpoint") {
|
||||
items.push({ kind: "checkpoint", goal: e.goal, findings: e.findings });
|
||||
} else {
|
||||
const last = items[items.length - 1];
|
||||
const category = getFriendlyCategory(e.name);
|
||||
@@ -755,10 +674,6 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "checkpoint") {
|
||||
// Internal loop-control machinery — never shown to the user.
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TimelineToolGroup
|
||||
key={i}
|
||||
@@ -1481,25 +1396,6 @@ export function ChatPanel({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (ev.type === "checkpoint" && ev.goal) {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
if (msgIndex >= 0 && next[msgIndex]) {
|
||||
const tl = next[msgIndex].timeline ?? [];
|
||||
next[msgIndex] = {
|
||||
...next[msgIndex],
|
||||
timeline: [
|
||||
...tl,
|
||||
{
|
||||
kind: "checkpoint",
|
||||
goal: ev.goal!,
|
||||
findings: ev.findings!,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (ev.type === "text" && ev.text) {
|
||||
// Each text SSE event = one round of the model's text
|
||||
// output. Push a new "text" timeline entry so the
|
||||
|
||||
@@ -109,6 +109,8 @@ export async function callGeminiChat(opts: {
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
/** Cancels the in-flight generation when the user clicks Stop. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
@@ -129,6 +131,10 @@ export async function callGeminiChat(opts: {
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) config.tools = fns;
|
||||
|
||||
// Forward the request abort signal so a Stop click cancels the actual
|
||||
// Gemini generation instead of only short-circuiting the next round.
|
||||
if (opts.signal) config.abortSignal = opts.signal;
|
||||
|
||||
console.log("\n========================================================");
|
||||
console.log("➡️ [GEMINI API REQUEST]");
|
||||
console.log("========================================================");
|
||||
@@ -227,11 +233,16 @@ export async function callGeminiChat(opts: {
|
||||
finishReason: response.candidates?.[0]?.finishReason,
|
||||
};
|
||||
} catch (error) {
|
||||
const aborted =
|
||||
opts.signal?.aborted ||
|
||||
(error instanceof Error && error.name === "AbortError");
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error: aborted
|
||||
? "aborted"
|
||||
: `GoogleGenAI error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ export async function callOpenAiCompatibleChat(opts: {
|
||||
temperature?: number;
|
||||
/** Unused for OpenAI-compat; kept for call-site symmetry */
|
||||
includeThoughts?: boolean;
|
||||
/** Cancels the in-flight request when the user clicks Stop. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
@@ -330,13 +332,18 @@ export async function callOpenAiCompatibleChat(opts: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
opts.signal?.aborted || (e instanceof Error && e.name === "AbortError");
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
error: aborted
|
||||
? "aborted"
|
||||
: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_MODEL (default deepseek-chat)
|
||||
*/
|
||||
|
||||
import type { ChatMessage, ToolDefinition } from './gemini-chat';
|
||||
import { callGeminiChat } from './gemini-chat';
|
||||
import { callOpenAiCompatibleChat } from './openai-compatible-chat';
|
||||
import type { ChatMessage, ToolDefinition } from "./gemini-chat";
|
||||
import { callGeminiChat } from "./gemini-chat";
|
||||
import { callOpenAiCompatibleChat } from "./openai-compatible-chat";
|
||||
|
||||
export type VibnChatCallOpts = {
|
||||
systemPrompt: string;
|
||||
@@ -22,11 +22,13 @@ export type VibnChatCallOpts = {
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
/** Forwarded to the provider so a Stop click cancels the live request. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function callVibnChat(opts: VibnChatCallOpts) {
|
||||
const p = (process.env.VIBN_CHAT_PROVIDER || 'gemini').toLowerCase().trim();
|
||||
if (p === 'deepseek' || p === 'openai_compatible') {
|
||||
const p = (process.env.VIBN_CHAT_PROVIDER || "gemini").toLowerCase().trim();
|
||||
if (p === "deepseek" || p === "openai_compatible") {
|
||||
return callOpenAiCompatibleChat(opts);
|
||||
}
|
||||
return callGeminiChat(opts);
|
||||
|
||||
@@ -12,13 +12,7 @@ import type { ToolDefinition } from "./gemini-chat";
|
||||
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
|
||||
|
||||
export type AgentPhase =
|
||||
| "plan"
|
||||
| "recon"
|
||||
| "checkpoint"
|
||||
| "execute"
|
||||
| "verify"
|
||||
| "final";
|
||||
export type AgentPhase = "plan" | "recon" | "execute" | "verify" | "final";
|
||||
|
||||
export type TurnIntent =
|
||||
| "conversational"
|
||||
@@ -77,7 +71,12 @@ export function filterToolsForPhase(
|
||||
phase: AgentPhase,
|
||||
intent: TurnIntent,
|
||||
): ToolDefinition[] {
|
||||
if (phase === "recon" || phase === "verify") {
|
||||
// `verify` stays read-only: when the agent is checking its work it should
|
||||
// inspect, not start new edits. `recon` and `execute` both expose the full
|
||||
// toolset so the agent can read AND edit directly in one fluid turn — the
|
||||
// old read-only `recon` paired with the (now-removed) forced checkpoint to
|
||||
// gate editing, which made the agent plan and stall instead of acting.
|
||||
if (phase === "verify") {
|
||||
return tools.filter(
|
||||
(t) =>
|
||||
READ_ONLY_TOOLS.has(t.name) ||
|
||||
@@ -85,10 +84,7 @@ export function filterToolsForPhase(
|
||||
t.name === "browser_navigate",
|
||||
);
|
||||
}
|
||||
if (phase === "execute") {
|
||||
return tools; // All tools allowed
|
||||
}
|
||||
return tools; // Default fallback
|
||||
return tools; // recon + execute: all tools allowed
|
||||
}
|
||||
|
||||
export const VIBN_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
@@ -1887,6 +1883,7 @@ export async function executeMcpTool(
|
||||
mcpToken: string,
|
||||
baseUrl: string,
|
||||
projectId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (toolName === "github_search") return executeGithubSearch(args);
|
||||
if (toolName === "github_file") return executeGithubFile(args);
|
||||
@@ -1923,6 +1920,7 @@ export async function executeMcpTool(
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ action, params }),
|
||||
signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
return JSON.stringify(data.result ?? data.error ?? data, null, 2).slice(
|
||||
@@ -1930,8 +1928,14 @@ export async function executeMcpTool(
|
||||
8000,
|
||||
);
|
||||
} catch (e) {
|
||||
const aborted =
|
||||
signal?.aborted || (e instanceof Error && e.name === "AbortError");
|
||||
return JSON.stringify({
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
error: aborted
|
||||
? "aborted by user"
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user