chore: 发布前恢复与边界加固
This commit is contained in:
parent
cf4e1d7706
commit
0fbaf7d76a
@ -1,7 +1,7 @@
|
|||||||
工作流: "idea-to-product"
|
工作流: "idea-to-product"
|
||||||
项目: "Great Agent 2"
|
项目: "Great Agent 2"
|
||||||
版本: "0.3.0"
|
版本: "0.3.0"
|
||||||
当前阶段: "纵向功能开发"
|
当前阶段: "功能验收"
|
||||||
更新时间: "2026-08-14"
|
更新时间: "2026-08-14"
|
||||||
阻塞: null
|
阻塞: null
|
||||||
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
确认人: "用户"
|
确认人: "用户"
|
||||||
确认时间: "2026-08-12"
|
确认时间: "2026-08-12"
|
||||||
功能验收:
|
功能验收:
|
||||||
状态: "未开始"
|
状态: "待确认"
|
||||||
确认人: null
|
确认人: null
|
||||||
确认时间: null
|
确认时间: null
|
||||||
|
|
||||||
@ -53,4 +53,4 @@
|
|||||||
状态: "已完成"
|
状态: "已完成"
|
||||||
- 编号: "F-010"
|
- 编号: "F-010"
|
||||||
名称: "恢复、边界与发布前加固"
|
名称: "恢复、边界与发布前加固"
|
||||||
状态: "进行中"
|
状态: "已完成"
|
||||||
|
|||||||
@ -11,4 +11,14 @@ describe("Web Server 骨架", () => {
|
|||||||
expect(response.headers.get("X-Request-ID")).toBeTruthy();
|
expect(response.headers.get("X-Request-ID")).toBeTruthy();
|
||||||
expect(healthResponseSchema.parse(await response.json()).status).toBe("ok");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { createErrorHandler } from "../http/error-handler";
|
import { createErrorHandler } from "../http/error-handler";
|
||||||
import { requestId } from "../http/request-id";
|
import { requestId } from "../http/request-id";
|
||||||
@ -31,6 +32,19 @@ export function createApp(
|
|||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.use("*", requestId);
|
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.onError(createErrorHandler(logger));
|
||||||
app.route("/api", createHealthRoutes());
|
app.route("/api", createHealthRoutes());
|
||||||
if (conversations)
|
if (conversations)
|
||||||
|
|||||||
29
apps/web-server/src/composition/run-registry.test.ts
Normal file
29
apps/web-server/src/composition/run-registry.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ export class RunRegistry {
|
|||||||
|
|
||||||
async publish(event: RunEvent): Promise<void> {
|
async publish(event: RunEvent): Promise<void> {
|
||||||
const events = this.history.get(event.runId) ?? [];
|
const events = this.history.get(event.runId) ?? [];
|
||||||
|
if (events.some((current) => current.sequence === event.sequence)) return;
|
||||||
events.push(event);
|
events.push(event);
|
||||||
this.history.set(event.runId, events);
|
this.history.set(event.runId, events);
|
||||||
if (event.type === "run.completed" || event.type === "run.failed")
|
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[] {
|
events(runId: string): readonly RunEvent[] {
|
||||||
return this.history.get(runId) ?? [];
|
return this.history.get(runId) ?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,11 +101,13 @@ const runs = new AgentRunService(
|
|||||||
interactions,
|
interactions,
|
||||||
workspace,
|
workspace,
|
||||||
);
|
);
|
||||||
|
const runRegistry = new RunRegistry();
|
||||||
|
await runRegistry.hydrate(await runs.recoverInterrupted());
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
logger,
|
logger,
|
||||||
conversations,
|
conversations,
|
||||||
runs,
|
runs,
|
||||||
new RunRegistry(),
|
runRegistry,
|
||||||
projects,
|
projects,
|
||||||
interactions,
|
interactions,
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export function createRunRoutes(
|
|||||||
routes.get("/runs/:runId/events", (context) =>
|
routes.get("/runs/:runId/events", (context) =>
|
||||||
streamSSE(context, async (stream) => {
|
streamSSE(context, async (stream) => {
|
||||||
const runId = context.req.param("runId");
|
const runId = context.req.param("runId");
|
||||||
|
await registry.hydrate(await service.listEvents(runId));
|
||||||
for (const event of registry.events(runId))
|
for (const event of registry.events(runId))
|
||||||
await writeEvent(stream, event);
|
await writeEvent(stream, event);
|
||||||
if (registry.isFinished(runId) || registry.isPaused(runId)) {
|
if (registry.isFinished(runId) || registry.isPaused(runId)) {
|
||||||
|
|||||||
@ -63,11 +63,15 @@ export function createAppController(): AppController {
|
|||||||
error.val = "";
|
error.val = "";
|
||||||
toolActivities.val = [];
|
toolActivities.val = [];
|
||||||
try {
|
try {
|
||||||
[recent.val, projects.val, settings.val] = await Promise.all([
|
[recent.val, projects.val] = await Promise.all([
|
||||||
listConversations(),
|
listConversations(),
|
||||||
listProjects(),
|
listProjects(),
|
||||||
getSettings(),
|
|
||||||
]);
|
]);
|
||||||
|
try {
|
||||||
|
settings.val = await getSettings();
|
||||||
|
} catch {
|
||||||
|
settings.val = null;
|
||||||
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.val = readMessage(cause);
|
error.val = readMessage(cause);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 纵向功能
|
# 纵向功能
|
||||||
|
|
||||||
状态:开发中
|
状态:全部切片已完成,等待最终统一人工审核
|
||||||
需求版本:0.3.0(已确认、已冻结)
|
需求版本:0.3.0(已确认、已冻结)
|
||||||
实施方案版本:0.4.0(已确认)
|
实施方案版本:0.4.0(已确认)
|
||||||
项目骨架:已确认(2026-08-12)
|
项目骨架:已确认(2026-08-12)
|
||||||
@ -120,9 +120,18 @@
|
|||||||
|
|
||||||
### F-010——恢复、边界与发布前加固
|
### F-010——恢复、边界与发布前加固
|
||||||
|
|
||||||
- 状态:进行中
|
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||||
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
||||||
- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
|
- 主要验收: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 验证,当前进入功能验收阶段。建议按切片编号依次审核;只有用户明确回复“功能验收通过”后,阶段门才会标记为已确认。
|
||||||
|
|
||||||
## 后续版本想法
|
## 后续版本想法
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"typecheck": "bun run --filter '*' typecheck",
|
"typecheck": "bun run --filter '*' typecheck",
|
||||||
"check:file-size": "bun run scripts/check-file-size.ts",
|
"check:file-size": "bun run scripts/check-file-size.ts",
|
||||||
"check:architecture": "bun run scripts/check-architecture.ts",
|
"check:architecture": "bun run scripts/check-architecture.ts",
|
||||||
|
"check:secrets": "bun run scripts/check-secrets.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"build": "bun run --filter @great-agent/web build && bun run --filter @great-agent/web-server build",
|
"build": "bun run --filter @great-agent/web build && bun run --filter @great-agent/web-server build",
|
||||||
"start": "bun apps/web-server/dist/index.js"
|
"start": "bun apps/web-server/dist/index.js"
|
||||||
|
|||||||
@ -322,6 +322,22 @@ describe("AgentRunService", () => {
|
|||||||
expect(events.map((event) => event.type)).toContain("tool.completed");
|
expect(events.map((event) => event.type)).toContain("tool.completed");
|
||||||
expect(events.at(-1)?.type).toBe("run.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) {
|
function createFixture(model: ModelPort) {
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import {
|
|||||||
lastSequence,
|
lastSequence,
|
||||||
messagesThroughTrigger,
|
messagesThroughTrigger,
|
||||||
persistRunEvent,
|
persistRunEvent,
|
||||||
|
prepareRetryRun,
|
||||||
requireRun,
|
requireRun,
|
||||||
|
recoverInterruptedRun,
|
||||||
safeErrorMessage,
|
safeErrorMessage,
|
||||||
} from "./run-service-helpers";
|
} from "./run-service-helpers";
|
||||||
import type { StartedRun, StartRunInput } from "./run-types";
|
import type { StartedRun, StartRunInput } from "./run-types";
|
||||||
@ -140,32 +142,13 @@ export class AgentRunService {
|
|||||||
|
|
||||||
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
|
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
|
||||||
return this.withStartLock(async () => {
|
return this.withStartLock(async () => {
|
||||||
await ensureNoActiveRun(this.runs);
|
const run = await prepareRetryRun(
|
||||||
const original = await requireRun(this.runs, runId);
|
runId,
|
||||||
if (original.status !== "failed" && original.status !== "cancelled")
|
this.conversations,
|
||||||
throw new CoreError(
|
this.runs,
|
||||||
"RUN_NOT_RETRYABLE",
|
this.clock,
|
||||||
"只有失败或已取消的任务可以重试",
|
this.ids,
|
||||||
);
|
|
||||||
const conversation = await this.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 = 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);
|
return this.started(run, signal, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -179,6 +162,21 @@ export class AgentRunService {
|
|||||||
return requireRun(this.runs, runId);
|
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(
|
private started(
|
||||||
run: AgentRun,
|
run: AgentRun,
|
||||||
externalSignal: AbortSignal,
|
externalSignal: AbortSignal,
|
||||||
|
|||||||
@ -117,6 +117,10 @@ describe("等待用户的 Run", () => {
|
|||||||
const interaction = [...fixture.interactions.values.values()][0];
|
const interaction = [...fixture.interactions.values.values()][0];
|
||||||
expect(interaction?.status).toBe("pending");
|
expect(interaction?.status).toBe("pending");
|
||||||
if (!interaction) throw new Error("interaction missing");
|
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, {
|
const resolved = await fixture.interactionService.resolve(interaction.id, {
|
||||||
kind: "choice",
|
kind: "choice",
|
||||||
selectedOptionIds: ["simple"],
|
selectedOptionIds: ["simple"],
|
||||||
|
|||||||
@ -21,6 +21,10 @@ export class InteractionService {
|
|||||||
return this.dependencies.interactions.listByConversation(conversationId);
|
return this.dependencies.interactions.listByConversation(conversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findPendingByRun(runId: string) {
|
||||||
|
return this.dependencies.interactions.findPendingByRun(runId);
|
||||||
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<UserInteraction> {
|
async get(id: string): Promise<UserInteraction> {
|
||||||
const interaction = await this.dependencies.interactions.getById(id);
|
const interaction = await this.dependencies.interactions.getById(id);
|
||||||
if (!interaction)
|
if (!interaction)
|
||||||
|
|||||||
@ -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 type { Conversation } from "../domain/conversation";
|
||||||
import { CoreError } from "../errors/core-error";
|
import { CoreError } from "../errors/core-error";
|
||||||
import type { RunRepository } from "../ports/run-repository";
|
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 {
|
export function lastSequence(events: readonly RunEvent[]): number {
|
||||||
return events.at(-1)?.sequence ?? 0;
|
return events.at(-1)?.sequence ?? 0;
|
||||||
@ -38,6 +39,60 @@ export async function requireRun(runs: RunRepository, id: string) {
|
|||||||
return run;
|
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(
|
export async function persistRunEvent(
|
||||||
runs: RunRepository,
|
runs: RunRepository,
|
||||||
clock: ClockPort,
|
clock: ClockPort,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { Conversation } from "@great-agent/agent-core";
|
import type { Conversation } from "@great-agent/agent-core";
|
||||||
@ -41,5 +41,7 @@ describe("FileConversationRepository", () => {
|
|||||||
expect((await repository.getById(conversation.id))?.messages).toHaveLength(
|
expect((await repository.getById(conversation.id))?.messages).toHaveLength(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
await writeFile(join(layout.conversations, "damaged.json"), "{broken");
|
||||||
|
expect(await repository.listRecent()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -91,17 +91,25 @@ export class FileConversationRepository implements ConversationRepository {
|
|||||||
private async readAll(): Promise<Conversation[]> {
|
private async readAll(): Promise<Conversation[]> {
|
||||||
await mkdir(this.layout.conversations, { recursive: true });
|
await mkdir(this.layout.conversations, { recursive: true });
|
||||||
const names = await readdir(this.layout.conversations);
|
const names = await readdir(this.layout.conversations);
|
||||||
return Promise.all(
|
const values = await Promise.all(
|
||||||
names
|
names
|
||||||
.filter((name) => name.endsWith(".json"))
|
.filter((name) => name.endsWith(".json"))
|
||||||
.map(async (name) => {
|
.map(async (name) => {
|
||||||
return normalizeConversation(
|
try {
|
||||||
JSON.parse(
|
return normalizeConversation(
|
||||||
await readFile(join(this.layout.conversations, name), "utf8"),
|
JSON.parse(
|
||||||
) as Conversation,
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,15 +61,22 @@ export class FileInteractionRepository implements InteractionRepository {
|
|||||||
const directory = this.directory(runId);
|
const directory = this.directory(runId);
|
||||||
try {
|
try {
|
||||||
const names = await readdir(directory);
|
const names = await readdir(directory);
|
||||||
return Promise.all(
|
const values = await Promise.all(
|
||||||
names
|
names
|
||||||
.filter((name) => name.endsWith(".json"))
|
.filter((name) => name.endsWith(".json"))
|
||||||
.map(
|
.map(async (name) => {
|
||||||
async (name) =>
|
try {
|
||||||
JSON.parse(
|
return JSON.parse(
|
||||||
await readFile(join(directory, name), "utf8"),
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||||
|
|||||||
@ -13,16 +13,20 @@ export class FileProjectRepository implements ProjectRepository {
|
|||||||
const projects = await Promise.all(
|
const projects = await Promise.all(
|
||||||
names
|
names
|
||||||
.filter((name) => name.endsWith(".json"))
|
.filter((name) => name.endsWith(".json"))
|
||||||
.map(
|
.map(async (name) => {
|
||||||
async (name) =>
|
try {
|
||||||
JSON.parse(
|
return JSON.parse(
|
||||||
await readFile(join(this.layout.projects, name), "utf8"),
|
await readFile(join(this.layout.projects, name), "utf8"),
|
||||||
) as Project,
|
) as Project;
|
||||||
),
|
} catch (cause) {
|
||||||
);
|
if (cause instanceof SyntaxError) return null;
|
||||||
return projects.sort((left, right) =>
|
throw cause;
|
||||||
right.updatedAt.localeCompare(left.updatedAt),
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
return projects
|
||||||
|
.filter((project): project is Project => project !== null)
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<Project | null> {
|
async getById(id: string): Promise<Project | null> {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { AgentRun, RunEvent } from "@great-agent/agent-core";
|
import type { AgentRun, RunEvent } from "@great-agent/agent-core";
|
||||||
@ -41,5 +41,10 @@ describe("FileRunRepository", () => {
|
|||||||
"message_1",
|
"message_1",
|
||||||
);
|
);
|
||||||
expect(await repository.listEvents(run.id)).toEqual([event]);
|
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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,11 +48,16 @@ export class FileRunRepository implements RunRepository {
|
|||||||
async listEvents(runId: string): Promise<readonly RunEvent[]> {
|
async listEvents(runId: string): Promise<readonly RunEvent[]> {
|
||||||
try {
|
try {
|
||||||
const text = await readFile(this.eventsPath(runId), "utf8");
|
const text = await readFile(this.eventsPath(runId), "utf8");
|
||||||
return text
|
const lines = text.split("\n").filter(Boolean);
|
||||||
.trim()
|
const events: RunEvent[] = [];
|
||||||
.split("\n")
|
for (const [index, line] of lines.entries()) {
|
||||||
.filter(Boolean)
|
try {
|
||||||
.map((line) => JSON.parse(line) as RunEvent);
|
events.push(JSON.parse(line) as RunEvent);
|
||||||
|
} catch (cause) {
|
||||||
|
if (index !== lines.length - 1) throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||||
return [];
|
return [];
|
||||||
@ -93,7 +98,14 @@ export class FileRunRepository implements RunRepository {
|
|||||||
const values = await Promise.all(
|
const values = await Promise.all(
|
||||||
names
|
names
|
||||||
.filter((entry) => entry.isDirectory())
|
.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);
|
return values.filter((run): run is AgentRun => run !== null);
|
||||||
}
|
}
|
||||||
|
|||||||
23
scripts/check-secrets.ts
Normal file
23
scripts/check-secrets.ts
Normal 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("敏感信息检查通过。");
|
||||||
Loading…
x
Reference in New Issue
Block a user