chore: 发布前恢复与边界加固

This commit is contained in:
李岩岩 2026-08-14 16:21:09 +08:00
parent cf4e1d7706
commit 0fbaf7d76a
22 changed files with 279 additions and 63 deletions

View File

@ -1,7 +1,7 @@
工作流: "idea-to-product"
项目: "Great Agent 2"
版本: "0.3.0"
当前阶段: "纵向功能开发"
当前阶段: "功能验收"
更新时间: "2026-08-14"
阻塞: null
@ -19,7 +19,7 @@
确认人: "用户"
确认时间: "2026-08-12"
功能验收:
状态: "未开始"
状态: "待确认"
确认人: null
确认时间: null
@ -53,4 +53,4 @@
状态: "已完成"
- 编号: "F-010"
名称: "恢复、边界与发布前加固"
状态: "进行中"
状态: "已完成"

View File

@ -11,4 +11,14 @@ describe("Web Server 骨架", () => {
expect(response.headers.get("X-Request-ID")).toBeTruthy();
expect(healthResponseSchema.parse(await response.json()).status).toBe("ok");
});
test("拒绝超过 5 MiB 的 API 请求体", async () => {
const app = createApp(pino({ enabled: false }));
const response = await app.request("/api/oversized", {
method: "POST",
body: "x".repeat(5 * 1024 * 1024 + 1),
});
expect(response.status).toBe(413);
expect((await response.json()).error.code).toBe("REQUEST_TOO_LARGE");
});
});

View File

@ -1,4 +1,5 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { Logger } from "pino";
import { createErrorHandler } from "../http/error-handler";
import { requestId } from "../http/request-id";
@ -31,6 +32,19 @@ export function createApp(
): Hono {
const app = new Hono();
app.use("*", requestId);
app.use(
"/api/*",
bodyLimit({
maxSize: 5 * 1024 * 1024,
onError: (context) =>
context.json(
{
error: { code: "REQUEST_TOO_LARGE", message: "请求内容超过 5 MiB" },
},
413,
),
}),
);
app.onError(createErrorHandler(logger));
app.route("/api", createHealthRoutes());
if (conversations)

View File

@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import type { RunEvent } from "@great-agent/agent-core";
import { RunRegistry } from "./run-registry";
describe("RunRegistry", () => {
test("重启后补入持久化事件并去重终态", async () => {
const registry = new RunRegistry();
const events: RunEvent[] = [
{
runId: "run_1",
sequence: 1,
timestamp: "2026-08-14T00:00:00Z",
type: "run.started",
payload: {},
},
{
runId: "run_1",
sequence: 2,
timestamp: "2026-08-14T00:00:01Z",
type: "run.completed",
payload: {},
},
];
await registry.hydrate(events);
await registry.hydrate(events);
expect(registry.events("run_1")).toHaveLength(2);
expect(registry.isFinished("run_1")).toBe(true);
});
});

View File

@ -10,6 +10,7 @@ export class RunRegistry {
async publish(event: RunEvent): Promise<void> {
const events = this.history.get(event.runId) ?? [];
if (events.some((current) => current.sequence === event.sequence)) return;
events.push(event);
this.history.set(event.runId, events);
if (event.type === "run.completed" || event.type === "run.failed")
@ -28,6 +29,13 @@ export class RunRegistry {
);
}
async hydrate(events: readonly RunEvent[]): Promise<void> {
for (const event of [...events].sort(
(left, right) => left.sequence - right.sequence,
))
await this.publish(event);
}
events(runId: string): readonly RunEvent[] {
return this.history.get(runId) ?? [];
}

View File

@ -101,11 +101,13 @@ const runs = new AgentRunService(
interactions,
workspace,
);
const runRegistry = new RunRegistry();
await runRegistry.hydrate(await runs.recoverInterrupted());
const app = createApp(
logger,
conversations,
runs,
new RunRegistry(),
runRegistry,
projects,
interactions,
workspace,

View File

@ -55,6 +55,7 @@ export function createRunRoutes(
routes.get("/runs/:runId/events", (context) =>
streamSSE(context, async (stream) => {
const runId = context.req.param("runId");
await registry.hydrate(await service.listEvents(runId));
for (const event of registry.events(runId))
await writeEvent(stream, event);
if (registry.isFinished(runId) || registry.isPaused(runId)) {

View File

@ -63,11 +63,15 @@ export function createAppController(): AppController {
error.val = "";
toolActivities.val = [];
try {
[recent.val, projects.val, settings.val] = await Promise.all([
[recent.val, projects.val] = await Promise.all([
listConversations(),
listProjects(),
getSettings(),
]);
try {
settings.val = await getSettings();
} catch {
settings.val = null;
}
} catch (cause) {
error.val = readMessage(cause);
} finally {

View File

@ -1,6 +1,6 @@
# 纵向功能
状态:开发中
状态:全部切片已完成,等待最终统一人工审核
需求版本0.3.0(已确认、已冻结)
实施方案版本0.4.0(已确认)
项目骨架已确认2026-08-12
@ -120,9 +120,18 @@
### F-010——恢复、边界与发布前加固
- 状态:进行中
- 状态:已完成Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
- 主要验收AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
- 运行恢复:服务启动时检查遗留活动任务;执行中的 Run 转为带 `RUN_INTERRUPTED` 的可重试失败终态并补写持久化事件,等待用户回答且交互请求仍完整的 Run 保持等待状态。SSE 注册表会从本地事件日志恢复并按序去重,因此重连后可以重放已有事件。
- 数据容错:会话、项目、运行和交互列表会隔离单个损坏的 JSON 记录Run 事件日志允许忽略因意外断电产生的不完整末行,但中间损坏仍明确报错,避免悄悄跳过有效日志。设置加载失败不会阻断会话和项目历史进入页面。
- 发布边界:所有 API 请求体限制为 5 MiB超限返回稳定的 `REQUEST_TOO_LARGE`;新增敏感信息扫描,检查源代码和文档中的常见密钥、私钥及前端模型密钥引用。应用继续不包含身份认证端点,认证由部署时的反向代理负责。
- 自动化验证结果2026-08-14 通过全仓格式、代码、类型、文件规模、架构依赖和敏感信息检查39 项测试和 134 个断言全部通过Web 与 Web Server 生产构建成功。新增覆盖服务中断恢复、等待交互保留、事件重放去重、损坏记录隔离、不完整 NDJSON 末行及 5 MiB 请求边界。
- 人工操作结果2026-08-14 使用 F-009 的隔离数据目录重启实际生产构建首次页面恢复“Markdown 验收”最近会话,选择后完整恢复列表、引用、表格、代码块、长消息和失败重试状态,证明发布构建可从真实本地文件重新构建页面状态。
## 最终统一人工审核
F-001 至 F-010 均已完成开发和 Agent 验证,当前进入功能验收阶段。建议按切片编号依次审核;只有用户明确回复“功能验收通过”后,阶段门才会标记为已确认。
## 后续版本想法

View File

@ -16,6 +16,7 @@
"typecheck": "bun run --filter '*' typecheck",
"check:file-size": "bun run scripts/check-file-size.ts",
"check:architecture": "bun run scripts/check-architecture.ts",
"check:secrets": "bun run scripts/check-secrets.ts",
"test": "bun test",
"build": "bun run --filter @great-agent/web build && bun run --filter @great-agent/web-server build",
"start": "bun apps/web-server/dist/index.js"

View File

@ -322,6 +322,22 @@ describe("AgentRunService", () => {
expect(events.map((event) => event.type)).toContain("tool.completed");
expect(events.at(-1)?.type).toBe("run.completed");
});
test("启动恢复把遗留 running 任务转为可重试失败", async () => {
const fixture = createFixture({ async *stream() {} });
await fixture.runs.create({
id: "interrupted_run",
conversationId: "conversation",
triggerMessageId: "message",
status: "running",
createdAt: "2026-08-14T00:00:00Z",
updatedAt: "2026-08-14T00:00:00Z",
});
const events = await fixture.service.recoverInterrupted();
expect(events[0]?.type).toBe("run.failed");
expect(events[0]?.payload.code).toBe("RUN_INTERRUPTED");
expect(fixture.runs.value?.status).toBe("failed");
});
});
function createFixture(model: ModelPort) {

View File

@ -15,7 +15,9 @@ import {
lastSequence,
messagesThroughTrigger,
persistRunEvent,
prepareRetryRun,
requireRun,
recoverInterruptedRun,
safeErrorMessage,
} from "./run-service-helpers";
import type { StartedRun, StartRunInput } from "./run-types";
@ -140,32 +142,13 @@ export class AgentRunService {
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
return this.withStartLock(async () => {
await ensureNoActiveRun(this.runs);
const original = await requireRun(this.runs, runId);
if (original.status !== "failed" && original.status !== "cancelled")
throw new CoreError(
"RUN_NOT_RETRYABLE",
"只有失败或已取消的任务可以重试",
);
const conversation = await this.conversations.getConversation(
original.conversationId,
const run = await prepareRetryRun(
runId,
this.conversations,
this.runs,
this.clock,
this.ids,
);
const trigger = conversation.messages.find(
(message) => message.id === original.triggerMessageId,
);
if (trigger?.role !== "user")
throw new CoreError("RUN_TRIGGER_INVALID", "原任务的用户消息不存在");
const timestamp = this.clock.now().toISOString();
const run: AgentRun = {
id: this.ids.create(),
conversationId: original.conversationId,
triggerMessageId: original.triggerMessageId,
retryOfRunId: original.id,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
};
await this.runs.create(run);
return this.started(run, signal, 0);
});
}
@ -179,6 +162,21 @@ export class AgentRunService {
return requireRun(this.runs, runId);
}
async listEvents(runId: string): Promise<readonly RunEvent[]> {
await requireRun(this.runs, runId);
return this.runs.listEvents(runId);
}
async recoverInterrupted(): Promise<readonly RunEvent[]> {
const active = await this.runs.findActive();
if (
active?.status === "waiting_user" &&
(await this.interactions.findPendingByRun(active.id))
)
return [];
return recoverInterruptedRun(this.runs, this.clock);
}
private started(
run: AgentRun,
externalSignal: AbortSignal,

View File

@ -117,6 +117,10 @@ describe("等待用户的 Run", () => {
const interaction = [...fixture.interactions.values.values()][0];
expect(interaction?.status).toBe("pending");
if (!interaction) throw new Error("interaction missing");
expect(await fixture.service.recoverInterrupted()).toEqual([]);
expect(fixture.runs.values.get(started.run.id)?.status).toBe(
"waiting_user",
);
const resolved = await fixture.interactionService.resolve(interaction.id, {
kind: "choice",
selectedOptionIds: ["simple"],

View File

@ -21,6 +21,10 @@ export class InteractionService {
return this.dependencies.interactions.listByConversation(conversationId);
}
findPendingByRun(runId: string) {
return this.dependencies.interactions.findPendingByRun(runId);
}
async get(id: string): Promise<UserInteraction> {
const interaction = await this.dependencies.interactions.getById(id);
if (!interaction)

View File

@ -1,8 +1,9 @@
import type { RunEvent } from "../domain/agent-run";
import type { AgentRun, RunEvent } from "../domain/agent-run";
import type { Conversation } from "../domain/conversation";
import { CoreError } from "../errors/core-error";
import type { RunRepository } from "../ports/run-repository";
import type { ClockPort } from "../ports/system-ports";
import type { ClockPort, IdPort } from "../ports/system-ports";
import type { ConversationService } from "./conversation-service";
export function lastSequence(events: readonly RunEvent[]): number {
return events.at(-1)?.sequence ?? 0;
@ -38,6 +39,60 @@ export async function requireRun(runs: RunRepository, id: string) {
return run;
}
export async function prepareRetryRun(
runId: string,
conversations: ConversationService,
runs: RunRepository,
clock: ClockPort,
ids: IdPort,
): Promise<AgentRun> {
await ensureNoActiveRun(runs);
const original = await requireRun(runs, runId);
if (original.status !== "failed" && original.status !== "cancelled")
throw new CoreError("RUN_NOT_RETRYABLE", "只有失败或已取消的任务可以重试");
const conversation = await conversations.getConversation(
original.conversationId,
);
const trigger = conversation.messages.find(
(message) => message.id === original.triggerMessageId,
);
if (trigger?.role !== "user")
throw new CoreError("RUN_TRIGGER_INVALID", "原任务的用户消息不存在");
const timestamp = clock.now().toISOString();
const run: AgentRun = {
id: ids.create(),
conversationId: original.conversationId,
triggerMessageId: original.triggerMessageId,
retryOfRunId: original.id,
status: "running",
createdAt: timestamp,
updatedAt: timestamp,
};
await runs.create(run);
return run;
}
export async function recoverInterruptedRun(
runs: RunRepository,
clock: ClockPort,
): Promise<readonly RunEvent[]> {
const run = await runs.findActive();
if (!run) return [];
const sequence = lastSequence(await runs.listEvents(run.id)) + 1;
await runs.update({
...run,
status: "failed",
errorCode: "RUN_INTERRUPTED",
updatedAt: clock.now().toISOString(),
});
return [
await persistRunEvent(runs, clock, run.id, sequence, "run.failed", {
code: "RUN_INTERRUPTED",
message: "服务重启中断了本次任务,请重试",
}),
];
}
export async function persistRunEvent(
runs: RunRepository,
clock: ClockPort,

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Conversation } from "@great-agent/agent-core";
@ -41,5 +41,7 @@ describe("FileConversationRepository", () => {
expect((await repository.getById(conversation.id))?.messages).toHaveLength(
1,
);
await writeFile(join(layout.conversations, "damaged.json"), "{broken");
expect(await repository.listRecent()).toHaveLength(1);
});
});

View File

@ -91,17 +91,25 @@ export class FileConversationRepository implements ConversationRepository {
private async readAll(): Promise<Conversation[]> {
await mkdir(this.layout.conversations, { recursive: true });
const names = await readdir(this.layout.conversations);
return Promise.all(
const values = await Promise.all(
names
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
return normalizeConversation(
JSON.parse(
await readFile(join(this.layout.conversations, name), "utf8"),
) as Conversation,
);
try {
return normalizeConversation(
JSON.parse(
await readFile(join(this.layout.conversations, name), "utf8"),
) as Conversation,
);
} catch (cause) {
if (cause instanceof SyntaxError) return null;
throw cause;
}
}),
);
return values.filter(
(conversation): conversation is Conversation => conversation !== null,
);
}
}

View File

@ -61,15 +61,22 @@ export class FileInteractionRepository implements InteractionRepository {
const directory = this.directory(runId);
try {
const names = await readdir(directory);
return Promise.all(
const values = await Promise.all(
names
.filter((name) => name.endsWith(".json"))
.map(
async (name) =>
JSON.parse(
.map(async (name) => {
try {
return JSON.parse(
await readFile(join(directory, name), "utf8"),
) as UserInteraction,
),
) as UserInteraction;
} catch (cause) {
if (cause instanceof SyntaxError) return null;
throw cause;
}
}),
);
return values.filter(
(interaction): interaction is UserInteraction => interaction !== null,
);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT")

View File

@ -13,16 +13,20 @@ export class FileProjectRepository implements ProjectRepository {
const projects = await Promise.all(
names
.filter((name) => name.endsWith(".json"))
.map(
async (name) =>
JSON.parse(
.map(async (name) => {
try {
return JSON.parse(
await readFile(join(this.layout.projects, name), "utf8"),
) as Project,
),
);
return projects.sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt),
) as Project;
} catch (cause) {
if (cause instanceof SyntaxError) return null;
throw cause;
}
}),
);
return projects
.filter((project): project is Project => project !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async getById(id: string): Promise<Project | null> {

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { appendFile, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AgentRun, RunEvent } from "@great-agent/agent-core";
@ -41,5 +41,10 @@ describe("FileRunRepository", () => {
"message_1",
);
expect(await repository.listEvents(run.id)).toEqual([event]);
await appendFile(
join(layout.runs, run.id, "events.ndjson"),
'{"sequence":2',
);
expect(await repository.listEvents(run.id)).toEqual([event]);
});
});

View File

@ -48,11 +48,16 @@ export class FileRunRepository implements RunRepository {
async listEvents(runId: string): Promise<readonly RunEvent[]> {
try {
const text = await readFile(this.eventsPath(runId), "utf8");
return text
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as RunEvent);
const lines = text.split("\n").filter(Boolean);
const events: RunEvent[] = [];
for (const [index, line] of lines.entries()) {
try {
events.push(JSON.parse(line) as RunEvent);
} catch (cause) {
if (index !== lines.length - 1) throw cause;
}
}
return events;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT")
return [];
@ -93,7 +98,14 @@ export class FileRunRepository implements RunRepository {
const values = await Promise.all(
names
.filter((entry) => entry.isDirectory())
.map((entry) => this.getById(entry.name)),
.map(async (entry) => {
try {
return await this.getById(entry.name);
} catch (cause) {
if (cause instanceof SyntaxError) return null;
throw cause;
}
}),
);
return values.filter((run): run is AgentRun => run !== null);
}

23
scripts/check-secrets.ts Normal file
View File

@ -0,0 +1,23 @@
const excluded = /(^|\/)(node_modules|dist|data|workspace|\.git)(\/|$)/u;
const suspicious = [
/sk-[A-Za-z0-9_-]{20,}/u,
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/u,
];
const violations: string[] = [];
for await (const path of new Bun.Glob(
"**/*.{ts,js,json,md,yaml,yml,css,html}",
).scan(".")) {
if (excluded.test(path)) continue;
const content = await Bun.file(path).text();
if (suspicious.some((pattern) => pattern.test(content)))
violations.push(`${path}: 疑似包含真实凭据`);
if (path.startsWith("apps/web/") && content.includes("DEEPSEEK_API_KEY"))
violations.push(`${path}: 客户端不得引用模型密钥环境变量`);
}
if (violations.length) {
console.error(violations.join("\n"));
process.exit(1);
}
console.log("敏感信息检查通过。");