Commit f86dd1a6 authored by xuchentao's avatar xuchentao

feat: add authenticated workspace drafts

parent 10c7988e
PORT=3100
HOST=127.0.0.1
FRONTEND_ORIGIN=http://localhost
# 单用户登录账号固定为 user;生产环境请务必修改默认密码。
WEBAGENT_USER_PASSWORD=user
# 相对路径以 WebAgent 项目根目录为基准,也支持 /data/WebAgent-sites 等绝对路径。
WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。
......
......@@ -20,7 +20,7 @@ export class BuildManager {
output += install.stdout + "\n" + install.stderr + "\n";
const build = await execa("pnpm", ["run", "build"], {
cwd: projectPath,
env: { ...process.env, SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" },
env: { ...process.env, CI: "true", SITE_BASE_PATH: basePath.replace(/\/$/, "") || "/" },
});
output += build.stdout + "\n" + build.stderr;
await writeFile(path.join(runtimePaths.logs, taskId + ".log"), output, "utf8");
......
......@@ -22,6 +22,10 @@ export const config = {
host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
auth: {
username: "user",
password: process.env.WEBAGENT_USER_PASSWORD || "user",
},
openai: {
apiKey: process.env.OPENAI_API_KEY || "",
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
......
......@@ -45,6 +45,23 @@ export class GitManager {
await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath });
}
async createPersistentWorktree(projectPath: string, workspacePath: string, reference = "HEAD"): Promise<void> {
await this.removePersistentWorktree(projectPath, workspacePath);
await mkdir(path.dirname(workspacePath), { recursive: true });
await execa("git", ["worktree", "add", "--detach", workspacePath, reference], { cwd: projectPath });
}
async removePersistentWorktree(projectPath: string, workspacePath: string): Promise<void> {
await execa("git", ["worktree", "remove", "--force", workspacePath], { cwd: projectPath, reject: false });
await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false });
await rm(workspacePath, { recursive: true, force: true });
}
async hasChanges(workspacePath: string): Promise<boolean> {
const result = await execa("git", ["status", "--porcelain"], { cwd: workspacePath });
return result.stdout.trim().length > 0;
}
async removeWorktree(projectPath: string, workspacePath: string): Promise<void> {
await execa("git", ["worktree", "remove", "--force", workspacePath], { cwd: projectPath, reject: false });
await execa("git", ["worktree", "prune"], { cwd: projectPath, reject: false });
......@@ -69,6 +86,14 @@ export class GitManager {
return this.currentCommit(projectPath);
}
async fastForward(projectPath: string, commit: string, expectedBase: string): Promise<string> {
const current = await this.currentCommit(projectPath);
if (current === commit) return current;
if (current !== expectedBase) throw new Error("最近保存版本已变化,请刷新后重新预览草稿");
await execa("git", ["merge", "--ff-only", commit], { cwd: projectPath });
return this.currentCommit(projectPath);
}
async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> {
await this.assertCommit(projectPath, sourceCommit);
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", ".", ":(exclude)astro.config.mjs"], { cwd: projectPath });
......
......@@ -10,6 +10,11 @@ export const createSiteSchema = z.object({
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/, "品牌色必须是 6 位十六进制颜色"),
});
export const loginSchema = z.object({
username: z.string().trim().min(1),
password: z.string().min(1),
});
export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000),
});
......
import path from "node:path";
import crypto from "node:crypto";
import { access, mkdir, rm } from "node:fs/promises";
import Fastify from "fastify";
import cors from "@fastify/cors";
......@@ -8,7 +9,7 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, versionCommitSchema } from "./schemas.js";
import { chatSchema, createSiteSchema, loginSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js";
......@@ -22,10 +23,33 @@ const git = new GitManager();
const builds = new BuildManager();
const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews);
const siteAgent = new SiteAgentService(sites, git, builds, previews, new AgentLoop());
const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews);
const sessions = new Set<string>();
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/") || request.url === "/api/health" || request.url === "/api/login") return;
const token = request.headers.authorization?.replace(/^Bearer\s+/i, "");
if (!token || !sessions.has(token)) return reply.code(401).send({ error: "登录已失效,请重新登录" });
});
app.get("/api/health", async () => ({ status: "ok", service: "WebAgent", agentMode: config.openai.apiKey ? "model" : "local", timestamp: new Date().toISOString() }));
app.post("/api/login", async (request, reply) => {
const body = loginSchema.parse(request.body);
const digest = (value: string) => crypto.createHash("sha256").update(value).digest();
const validUsername = crypto.timingSafeEqual(digest(body.username), digest(config.auth.username));
const validPassword = crypto.timingSafeEqual(digest(body.password), digest(config.auth.password));
if (!validUsername || !validPassword) return reply.code(401).send({ error: "账号或密码错误" });
const token = crypto.randomBytes(32).toString("hex");
sessions.add(token);
return { token, username: config.auth.username };
});
app.post("/api/logout", async (request) => {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, "");
if (token) sessions.delete(token);
return { success: true };
});
app.get("/api/session", async () => ({ username: config.auth.username }));
app.get("/api/sites", async () => sites.list());
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId", async (request) => sites.get(request.params.siteId));
app.post("/api/sites", async (request, reply) => reply.code(201).send(await createSite.execute(createSiteSchema.parse(request.body))));
......@@ -36,6 +60,7 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/build", async (requ
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", async (request) => {
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
});
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
......
import crypto from "node:crypto";
import path from "node:path";
import type { ChatResult, SitePatch } from "@webagent/shared";
import { getPublicPreviewUrl, runtimePaths } from "../config.js";
import { AgentLoop } from "../agent/agent-loop.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { validatePatch } from "../security/path-policy.js";
import { applyPatch } from "./apply-patch.js";
import { SiteRepository } from "./site-repository.js";
......@@ -13,56 +8,39 @@ import { SiteRepository } from "./site-repository.js";
export class SiteAgentService {
constructor(
private readonly sites: SiteRepository, private readonly git: GitManager,
private readonly builds: BuildManager, private readonly previews: PreviewProcessManager,
private readonly agent: AgentLoop,
) {}
async execute(siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前测试环境正在预览历史版本,请先切换到最近保存版本再继续修改");
}
const projectPath = this.sites.getProjectPath(siteId);
const taskId = "task_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
const workspace = this.sites.getDraftPath(siteId);
const baseCommit = site.draftBaseCommit || site.currentCommit;
const creatingDraft = !site.draftBaseCommit;
await this.sites.update(siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try {
await this.git.createWorktree(projectPath, workspace);
if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit);
generated = await this.agent.generate(workspace, message);
await validatePatch(generated.patch, workspace);
await applyPatch(workspace, generated.patch);
let dist: string;
try {
dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
} catch (error) {
if (!(error instanceof BuildError) || generated.mode !== "model") throw error;
const repair = await this.agent.repair(workspace, message, error.output);
await validatePatch(repair, workspace);
await applyPatch(workspace, repair);
const mergedOperations = new Map(
[...generated.patch.operations, ...repair.operations].map((operation) => [operation.path, operation]),
);
if (mergedOperations.size > 5) throw new Error("自动修复后的修改文件总数超过 5 个,任务已安全取消");
generated.patch = { summary: repair.summary, operations: [...mergedOperations.values()] };
dist = await this.builds.build(workspace, taskId + "_repair", getPublicPreviewUrl(siteId));
}
const changedFiles = generated.patch.operations.map((item) => item.path);
const worktreeCommit = await this.git.commitWorktree(workspace, "Agent: " + generated.patch.summary, changedFiles);
const commit = await this.git.cherryPick(projectPath, worktreeCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
const updatedAt = new Date().toISOString();
await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, environmentVersion: 3, lastError: undefined,
status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt,
draftSummary: generated.patch.summary, lastError: undefined,
});
return { summary: generated.patch.summary, commit, previewUrl: site.previewUrl, changedFiles, mode: generated.mode };
return { summary: generated.patch.summary, draft: true, baseCommit, updatedAt, changedFiles, mode: generated.mode };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
const details = error instanceof Error ? error.message : String(error);
const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false);
if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace);
await this.sites.update(siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: details,
draftBaseCommit: keepDraft ? baseCommit : undefined,
draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined,
});
throw error;
} finally {
await this.git.removeWorktree(projectPath, workspace);
}
}
}
......@@ -20,6 +20,10 @@ export class SiteRepository {
return path.join(this.getSiteRoot(siteId), "project");
}
getDraftPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "draft");
}
getMetadataPath(siteId: string): string {
return path.join(this.getSiteRoot(siteId), "metadata", "site.json");
}
......
......@@ -35,8 +35,40 @@ export class SiteVersionService {
return { commit: targetCommit, previewUrl: site.previewUrl };
}
async previewDraft(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
if (!site.draftBaseCommit) throw new Error("当前没有可预览的工作草稿");
const project = this.sites.getProjectPath(siteId);
const workspace = this.sites.getDraftPath(siteId);
const taskId = "draft_" + crypto.randomBytes(4).toString("hex");
const draftHead = await this.git.currentCommit(workspace);
if (draftHead === site.draftBaseCommit && !await this.git.hasChanges(workspace)) throw new Error("工作草稿没有实际修改");
await this.sites.update(siteId, { status: "building", lastError: undefined });
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(siteId));
const draftCommit = await this.git.commit(workspace, "Agent: " + (site.draftSummary || "保存工作草稿"));
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
const published = await this.builds.publishPreview(siteId, dist);
await this.previews.start(siteId, published, site.previewPort);
await this.sites.update(siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, draftBaseCommit: undefined,
draftUpdatedAt: undefined, draftSummary: undefined,
environmentVersion: 3, lastError: undefined,
});
await this.git.removePersistentWorktree(project, workspace);
return { commit, previewUrl: site.previewUrl };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
}
}
async undo(siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(siteId);
if (site.draftBaseCommit) throw new Error("工作草稿尚未预览,请先预览草稿或继续编辑");
const project = this.sites.getProjectPath(siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
......
......@@ -6,6 +6,7 @@
WebAgent-sites/
└── site_xxxxxx/
├── project/ # 独立 Astro 项目,也是独立 Git 仓库
├── draft/ # 持久化工作草稿 Git Worktree,不进入正式版本历史
└── metadata/
└── site.json # WebAgent 管理信息,不交给 Agent 修改
......@@ -27,9 +28,9 @@ WebAgent/.runtime/
## 不变量
1. Agent 只可读取和修改当前站点 `project/` 内的白名单文件。
2. Agent 永远不直接修改正式项目,先在 `builds/`验证。
3. 只有构建成功的版本才允许进入站点 Git 历史并成为预览版本
1. Agent 只可读取和修改当前站点草稿 Worktree 内的白名单文件。
2. Agent 永远不直接修改正式项目;修改自动保存到 `draft/`,用户选择“预览草稿”后再构建验证。
3. 只有草稿构建成功才允许提交到站点 Git 历史并成为预览版本;失败草稿继续保留
4. 测试预览更新不能覆盖生产产物;生产环境只能通过显式发布操作更新。
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
......
This diff is collapsed.
......@@ -2,6 +2,8 @@ import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult,
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
const token = sessionStorage.getItem("webagent-session");
if (token) headers.set("authorization", "Bearer " + token);
if (options?.body != null && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(url, {
...options,
......@@ -13,6 +15,9 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
}
export const api = {
login: (username: string, password: string) => request<{ token: string; username: string }>("/api/login", { method: "POST", body: JSON.stringify({ username, password }) }),
logout: () => request<{ success: true }>("/api/logout", { method: "POST" }),
session: () => request<{ username: string }>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
sites: () => request<SiteInfo[]>("/api/sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
......@@ -20,6 +25,7 @@ export const api = {
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
previewDraft: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-draft", { method: "POST" }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
......
This diff is collapsed.
......@@ -24,6 +24,9 @@ export interface SiteInfo {
currentCommit: string;
previewCommit?: string;
previousCommit?: string;
draftBaseCommit?: string;
draftUpdatedAt?: string;
draftSummary?: string;
publishedCommit?: string;
publishedAt?: string;
publishStatus: PublishStatus;
......@@ -52,8 +55,9 @@ export interface GitHistoryItem {
export interface ChatResult {
summary: string;
commit: string;
previewUrl: string;
draft: true;
baseCommit: string;
updatedAt: string;
changedFiles: string[];
mode: "model" | "local";
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment