Skip to content

Commit 0b4f757

Browse files
authored
Merge pull request #66 from fastclaw-ai/feat/coding-runtime-and-ui
feat: project runtime (coding-agent preview) + dashboard UI refinements
2 parents 9970e5a + ff7ed19 commit 0b4f757

25 files changed

Lines changed: 2280 additions & 136 deletions

cmd/fastclaw/main.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"github.com/fastclaw-ai/fastclaw/internal/config"
1717
"github.com/fastclaw-ai/fastclaw/internal/daemon"
1818
"github.com/fastclaw-ai/fastclaw/internal/gateway"
19+
coderuntime "github.com/fastclaw-ai/fastclaw/internal/runtime"
20+
"github.com/fastclaw-ai/fastclaw/internal/sandbox"
1921
"github.com/fastclaw-ai/fastclaw/internal/setup"
2022
"github.com/fastclaw-ai/fastclaw/internal/store"
2123
)
@@ -185,6 +187,51 @@ func runGateway(port int) error {
185187
apiSrv := api.NewServer(&apiResolver{gw: gw}, authResolver, gwCfg)
186188
webSrv.SetAPIServer(apiSrv)
187189

190+
// Coding-agent project runtime: long-lived dev-server sandbox +
191+
// preview URL, layered on top of the existing project feature. Wired
192+
// for the docker sandbox backend; other backends leave it nil and the
193+
// /runtime endpoints return 503. Template scaffold/dev commands are
194+
// env-overridable so fastclaw stays template-agnostic — the default
195+
// targets a ShipAny image with the template baked at /template.
196+
if home, herr := config.HomeDir(); herr == nil {
197+
rtMgr := coderuntime.NewManager(
198+
gw.Store(), home, env.Sandbox.Image,
199+
&sandbox.Policy{}, os.Getenv("FASTCLAW_PREVIEW_BASE"))
200+
rtMgr.RegisterTemplate("shipany-tanstack", coderuntime.TemplateSpec{
201+
DevPort: 3000,
202+
// Default scaffold, validated end-to-end against
203+
// thinkany/fastclaw-sandbox (node+npm, no pnpm):
204+
// 1. copy the template EXCLUDING node_modules — the host
205+
// checkout's are platform-specific + huge; a fresh
206+
// in-container install is correct.
207+
// 2. ensure pnpm (base image ships node+npm only).
208+
// 3. verify-deps-before-run=false — pnpm 11 otherwise re-runs
209+
// `install` before every `dev`, which keeps failing on the
210+
// ignored-builds gate and aborts the dev server.
211+
// 4. `install || true` — the ignored-builds gate exits non-zero
212+
// AFTER deps are on disk, so tolerate it.
213+
// 5. `pnpm rebuild` — actually build esbuild/sharp so Vite runs.
214+
// Override wholesale with FASTCLAW_SHIPANY_SCAFFOLD.
215+
ScaffoldCmd: envOr("FASTCLAW_SHIPANY_SCAFFOLD",
216+
"set -e; if [ -d /template ]; then tar -C /template "+
217+
"--exclude=node_modules --exclude=.git --exclude=.output --exclude=dist "+
218+
"-cf - . | tar -C /workspace -xf -; fi; cd /workspace; "+
219+
"command -v pnpm >/dev/null 2>&1 || npm i -g pnpm; "+
220+
"pnpm config set verify-deps-before-run false; "+
221+
"pnpm install || true; pnpm rebuild"),
222+
DevCmd: envOr("FASTCLAW_SHIPANY_DEV", "pnpm dev --host 0.0.0.0 --port 3000"),
223+
// Local template checkout bind-mounted at /template (option C):
224+
// set FASTCLAW_SHIPANY_TEMPLATE_DIR=~/code/shipany-tanstack to
225+
// scaffold from disk without baking the image or cloning.
226+
TemplateMount: os.Getenv("FASTCLAW_SHIPANY_TEMPLATE_DIR"),
227+
})
228+
webSrv.SetRuntimeManager(rtMgr) // HTTP /runtime endpoints
229+
gw.SetProjectRuntime(rtMgr) // agent preview tools
230+
slog.Info("project runtime enabled",
231+
"previewBase", os.Getenv("FASTCLAW_PREVIEW_BASE"),
232+
"templateDir", os.Getenv("FASTCLAW_SHIPANY_TEMPLATE_DIR"))
233+
}
234+
188235
bindMode := gwCfg.Bind
189236
if bindMode == "" {
190237
bindMode = "loopback"
@@ -209,6 +256,14 @@ func runGateway(port int) error {
209256
return gw.Run()
210257
}
211258

259+
// envOr returns the value of env var key, or def when it's unset/empty.
260+
func envOr(key, def string) string {
261+
if v := os.Getenv(key); v != "" {
262+
return v
263+
}
264+
return def
265+
}
266+
212267
func countUsersSafe(gw *gateway.Gateway) (int, error) {
213268
st := gw.Store()
214269
if st == nil {

docs/coding-agent-runtime.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Coding-Agent Project Runtime
2+
3+
This document is the integration contract for the **coding-agent runtime**:
4+
the layer that lets fastclaw scaffold a project from a template, run its
5+
dev server in a long-lived sandbox, and hand back a live preview URL. The
6+
upstream SaaS shell drives everything through the HTTP API below — it
7+
never touches the sandbox, the LLM, or the filesystem directly.
8+
9+
## Mental model: two layers, one project
10+
11+
| Layer | Owns | Table | Lifecycle |
12+
|-------|------|-------|-----------|
13+
| **Project** (pre-existing) | the source tree (a shared workspace folder) + chat grouping | `projects` | created by the user, persists |
14+
| **Project Runtime** (new) | the *running instance* of that tree: a long-lived dev-server container + preview URL | `project_runtimes` | booted on demand, evictable |
15+
16+
A runtime is 1:1 with a project, keyed by the same `(user_id, agent_id,
17+
project_id)`. The two are separate tables on purpose: dropping every
18+
`project_runtimes` row degrades gracefully to "no previews" and never
19+
touches chat history or workspace files. The existing project feature is
20+
byte-for-byte unchanged.
21+
22+
**Why the preview reflects agent edits live:** the runtime container and
23+
the agent's per-turn sandbox bind-mount the *same* host directory
24+
(`workspaces/<agent>/projects/<pid>/`). When the agent writes a file
25+
during a turn, the dev server in the runtime container sees it instantly
26+
and HMR reloads. No file sync — the bind mount is the channel.
27+
28+
## HTTP API
29+
30+
All endpoints are under the existing per-agent auth (`requireAgentReadable`
31+
for reads, `requireWritable` for mutations). Ownership is scoped to the
32+
caller's `user_id`, identical to `/projects`.
33+
34+
If the deployment hasn't wired a runtime manager (non-docker sandbox
35+
backend, or `SetRuntimeManager` never called), every endpoint returns
36+
`503 {"error":"project runtime not enabled on this deployment"}`.
37+
38+
### Runtime record shape
39+
40+
```jsonc
41+
{
42+
"projectId": "proj_ab12…",
43+
"templateRef": "shipany-tanstack",
44+
"status": "running", // none|scaffolding|starting|running|sleeping|crashed
45+
"devPort": 3000, // container-internal dev server port
46+
"hostPort": 49210, // published host port (0 when sleeping)
47+
"previewUrl": "https://proj_ab12.preview.example.com",
48+
"gitRef": "abc123", // last snapshot commit (for revert)
49+
"lastError": "", // populated on status=crashed
50+
"createdAt": "2026-06-12T…",
51+
"updatedAt": "2026-06-12T…"
52+
}
53+
```
54+
55+
### Endpoints
56+
57+
| Method & path | Purpose | Body | Returns |
58+
|---|---|---|---|
59+
| `GET /api/agents/{id}/projects/{pid}/runtime` | current state || runtime record, or `404` if none |
60+
| `POST /api/agents/{id}/projects/{pid}/runtime/up` | provision + boot (idempotent) | `{"templateRef":"shipany-tanstack"}` (required on first boot, ignored after) | runtime record (`status:running`) |
61+
| `POST /api/agents/{id}/projects/{pid}/runtime/sleep` | stop container, keep files || `{"ok":true,"status":"sleeping"}` |
62+
| `POST /api/agents/{id}/projects/{pid}/runtime/wake` | re-boot a sleeping runtime || runtime record |
63+
| `DELETE /api/agents/{id}/projects/{pid}/runtime` | tear down container + forget runtime (files kept) || `{"ok":true}` |
64+
| `GET /api/agents/{id}/projects/{pid}/preview` | preview URL + status only || `{"previewUrl":…,"status":…}` |
65+
| `GET /api/agents/{id}/projects/{pid}/runtime/logs?tail=200` | dev-server log tail || `{"logs":"…"}` |
66+
67+
`up` and `wake` may take minutes (scaffold + `pnpm install`); the handler
68+
allows a 10-minute deadline. The SaaS should show a "building…" state and
69+
poll `GET …/runtime` (or `…/preview`) until `status` is `running` or
70+
`crashed`.
71+
72+
### Typical SaaS flow ("make me an X")
73+
74+
1. `POST /api/agents/{id}/projects` → create the project (existing API), get `pid`.
75+
2. Send the build instruction via the existing chat API (`/api/chat/stream`)
76+
with `projectId=pid`. The coding-agent persona customizes the template.
77+
3. `POST …/{pid}/runtime/up` with `templateRef` → boots the dev server.
78+
4. Poll `GET …/{pid}/preview` until `status=running`, then iframe `previewUrl`.
79+
5. Further edits: just send more chat turns. HMR reflects them; no re-up needed.
80+
6. Idle: `POST …/sleep` to free compute; `POST …/wake` when the user returns.
81+
82+
## Using it from fastclaw's own web chat (no SaaS shell)
83+
84+
The runtime is also wired into the agent loop as two tools, so you can
85+
dogfood the whole loop in fastclaw's built-in web chat:
86+
87+
- `start_app_preview` — scaffolds the project from the template (first
88+
call), boots the dev server, returns the preview URL.
89+
- `app_preview_logs` — tails the dev-server log to debug a bad edit.
90+
91+
These tools appear **only** on agents that have a runtime wired
92+
(`SetProjectRuntime`), so ordinary agents are unaffected. When present,
93+
the system-prompt guidance flips from "don't start dev servers" to "use
94+
`start_app_preview` for web-app projects."
95+
96+
**Where the app is homed.** `start_app_preview` works in any chat:
97+
98+
- **Inside a project** → the app is homed at the project root
99+
(`projects/<pid>/`), shared and persistent across the project's chats.
100+
A coding agent's file tools address that root (not a per-chat subdir),
101+
so its edits land where the dev server serves them and HMR reloads.
102+
- **In a loose chat** (no project) → the app is homed in the chat's own
103+
workspace (`sessions/<sid>/`), which is exactly where the agent's edits
104+
already go. Great for one-off demos; the app lives with that chat.
105+
106+
So you do **not** need to pre-create a project — it's an optional upgrade
107+
for persistence/sharing. Plain agents (no runtime wired) are unaffected
108+
and keep per-chat isolation.
109+
110+
### Dogfood steps
111+
112+
1. Run fastclaw with the docker sandbox backend and a template source
113+
(see env vars below). For a local template checkout:
114+
```
115+
FASTCLAW_SHIPANY_TEMPLATE_DIR=/Users/you/code/shipany-tanstack
116+
```
117+
The sandbox image still needs node + pnpm.
118+
2. Open any chat (a project chat for a persistent app, or just a new
119+
loose chat for a quick demo).
120+
3. Say e.g. *"用 shipany 模板做个 AI 抠图落地页"*. The agent calls
121+
`start_app_preview` (scaffold + boot), edits the template's copy/theme,
122+
and replies with a preview URL. Leave `FASTCLAW_PREVIEW_BASE` empty and
123+
it's `http://127.0.0.1:<port>` — open it directly.
124+
4. Keep chatting to iterate; HMR reflects edits live.
125+
126+
> Note: the agent's `exec` tool still cwd's into the per-chat sandbox
127+
> subdir, so the runtime (not the agent) owns build/install/serve. The
128+
> agent edits files; the dev server rebuilds. If the agent needs to run a
129+
> project command itself it should `cd /workspace` first.
130+
131+
## Server wiring
132+
133+
`cmd/fastclaw/main.go` constructs the manager and registers the
134+
`shipany-tanstack` template when a home dir resolves. It's active for the
135+
docker sandbox backend; other backends leave the endpoints at `503`.
136+
137+
Template commands are env-overridable so fastclaw stays template-agnostic:
138+
139+
| Env var | Default | Meaning |
140+
|---|---|---|
141+
| `FASTCLAW_PREVIEW_BASE` | _(empty)_ | Preview URL template. Empty → `http://127.0.0.1:<hostPort>` (local). Set to `https://{project}.preview.example.com` for the wildcard gateway (the `{project}` token is replaced with the project id). |
142+
| `FASTCLAW_SHIPANY_SCAFFOLD` | `if [ -d /template ]; then cp -a /template/. /workspace/; fi; cd /workspace && (pnpm install \|\| npm install)` | Shell run once in `/workspace` when it's empty. Populates the source tree + installs deps. |
143+
| `FASTCLAW_SHIPANY_DEV` | `pnpm dev --host 0.0.0.0 --port 3000` | Shell that starts the dev server bound to `0.0.0.0:3000`. |
144+
| `FASTCLAW_SHIPANY_TEMPLATE_DIR` | _(empty)_ | Host dir bind-mounted read-only at `/template` in the runtime container (option C). Set to a local checkout (e.g. `~/code/shipany-tanstack`) to scaffold from disk — no image bake, no git clone. The default scaffold's `cp -a /template/.` then works. |
145+
146+
To add another template (e.g. a Next.js starter), call
147+
`rtMgr.RegisterTemplate("my-template", coderuntime.TemplateSpec{…})`
148+
nothing in the runtime is ShipAny-specific.
149+
150+
### Sandbox image requirements
151+
152+
The runtime reuses the sandbox image (`FASTCLAW_SANDBOX_IMAGE`). For the
153+
ShipAny template that image must have **node + pnpm** and the template
154+
source baked at `/template` (so the default scaffold's `cp -a /template/.`
155+
works). Alternatively override `FASTCLAW_SHIPANY_SCAFFOLD` to `git clone`
156+
the template instead.
157+
158+
## Preview gateway (deployment-side, NOT in this repo)
159+
160+
The runtime publishes the dev port to `127.0.0.1:<hostPort>` on the host —
161+
deliberately **not** `0.0.0.0`, because the container runs LLM-generated
162+
code and must never be directly reachable. Turning `hostPort` into a
163+
shareable URL is a reverse proxy you deploy alongside fastclaw:
164+
165+
```
166+
*.preview.example.com
167+
│ (wildcard DNS + wildcard TLS, e.g. Caddy / Traefik)
168+
169+
preview gateway ──looks up subdomain (= project_id) in project_runtimes──▶ 127.0.0.1:<hostPort>
170+
```
171+
172+
Gateway responsibilities:
173+
174+
- **Subdomain → host port.** Resolve `proj_ab12.preview.example.com` to the
175+
`host_port` of that project's `project_runtimes` row (query the same DB,
176+
or add a small internal lookup endpoint).
177+
- **Wildcard TLS** for `*.preview.example.com` (Let's Encrypt DNS-01).
178+
- **WebSocket passthrough** — Vite HMR runs over WS. Without it, edits
179+
won't hot-reload. The template must also advertise the *published host
180+
port* in its HMR config (`server.hmr.clientPort`), since inside the
181+
container the dev server only knows port 3000.
182+
183+
Set `FASTCLAW_PREVIEW_BASE=https://{project}.preview.example.com` so the
184+
runtime records gateway-shaped URLs; the gateway does the port mapping.
185+
186+
For local development leave `FASTCLAW_PREVIEW_BASE` empty and hit
187+
`http://127.0.0.1:<hostPort>` directly — no gateway needed.
188+
189+
## What's intentionally left to the integrator
190+
191+
- **Git snapshot / revert.** The record carries `gitRef`; wiring a
192+
`git commit` after each turn and a `/git/revert` endpoint is a thin
193+
follow-up using `Manager.Exec` (it runs commands in the runtime
194+
container). Not built yet.
195+
- **Idle auto-sleep.** `ListAllProjectRuntimes` exists for a sweeper, but
196+
the background eviction loop for runtimes is not wired (the per-turn
197+
pool has its own; this is the long-lived layer). Add a ticker that
198+
`Sleep`s runtimes idle past a TTL.
199+
- **Deploy.** "Ship to production" reuses the template's own deploy skill
200+
(e.g. ShipAny's `deploy-cloudflare`) via a chat turn or `Manager.Exec`.

internal/agent/loop.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/fastclaw-ai/fastclaw/internal/mcp"
2121
"github.com/fastclaw-ai/fastclaw/internal/privacy"
2222
"github.com/fastclaw-ai/fastclaw/internal/provider"
23+
coderuntime "github.com/fastclaw-ai/fastclaw/internal/runtime"
2324
"github.com/fastclaw-ai/fastclaw/internal/sandbox"
2425
"github.com/fastclaw-ai/fastclaw/internal/scope"
2526
"github.com/fastclaw-ai/fastclaw/internal/session"
@@ -118,6 +119,14 @@ type Agent struct {
118119
// and hook are simply not registered, so a missing store silently
119120
// degrades to "feature off" rather than crashing.
120121
goalStore goal.Store
122+
123+
// projectRuntime, when non-nil, turns this agent into a coding agent:
124+
// it can scaffold a project from a template, boot a dev server, and
125+
// hand back a preview URL via the start_app_preview / app_preview_logs
126+
// tools. Wired by attachProjectRuntimeToAgents at boot. Nil for
127+
// ordinary agents, which then never see those tools and keep their
128+
// per-chat file isolation. See SetProjectRuntime.
129+
projectRuntime *coderuntime.Manager
121130
}
122131

123132
// SetSandboxPool wires the per-(agent,session) executor pool. Called by
@@ -163,6 +172,24 @@ func (a *Agent) SetSandboxPool(p sandbox.ExecutorPool) {
163172
func (a *Agent) bindSession(ctx context.Context, channel, sessionID, projectID string) {
164173
a.registry.SetSessionID(sessionID)
165174
a.registry.SetProjectID(projectID)
175+
// Coding agents (those with a project runtime wired) treat a project
176+
// as ONE shared app tree: file tools address the project root so the
177+
// agent's edits land where the dev server serves. Only when actually
178+
// inside a project; loose chats and non-coding agents are unaffected.
179+
a.registry.SetCodingRootScope(a.projectRuntime != nil && projectID != "")
180+
// If this scope already has a running app (a runtime record exists),
181+
// redirect file tools into its app subfolder so edits keep landing
182+
// where the dev server serves — across turns, not just the turn that
183+
// called start_app_preview. EffectiveUserID is the owner here
184+
// (chatter is bound later), which is correct for the web-direct case.
185+
a.registry.SetCodingSubdir("")
186+
if a.projectRuntime != nil {
187+
if uid := a.registry.EffectiveUserID(); uid != "" {
188+
if _, err := a.projectRuntime.Get(ctx, uid, a.name, projectID, sessionID); err == nil {
189+
a.registry.SetCodingSubdir(coderuntime.AppSubdir)
190+
}
191+
}
192+
}
166193
a.registry.SetMessageContext(channel, sessionID)
167194
if a.sandboxPool == nil {
168195
return
@@ -2993,6 +3020,12 @@ var chatbotBuiltinAllowlist = []string{
29933020
// set_timezone keeps "their local time" right for chat (greetings,
29943021
// "晚安" timing) — chatbots need it as much as full agents do.
29953022
"set_timezone",
3023+
// Coding-agent preview tools. Only ever REGISTERED when a project
3024+
// runtime is wired (SetProjectRuntime), so listing them here is a
3025+
// harmless no-op for ordinary chat personas and makes the preview
3026+
// usable regardless of the agent's prompt mode.
3027+
"start_app_preview",
3028+
"app_preview_logs",
29963029
}
29973030

29983031
// builtinAllowForMode returns the built-in tool name allowlist for the

0 commit comments

Comments
 (0)