forked from EduCraft/curriculum-project-hub
e7924f78d5
A-3 结构化历史: - 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆) - 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation) - runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages - workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录 - runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange - ToolContext 加 onFileChange sink 字段 - trigger.ts 接线 runId/sessionId/prisma 到 RunRequest A-5 lock 心跳: - ProjectAgentLock 加 heartbeatAt 列 - runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release) 60 tests 全过(tsc 干净)。
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
/**
|
|
* Workspace file tools - bounded read/write/list against the project's
|
|
* curriculum engineering-file tree (ADR-0007 directory tree).
|
|
*
|
|
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
|
* requested relative path against the workspace root and rejects any path that
|
|
* escapes it (via `..` or absolute paths). This is the agent's only file
|
|
* surface; the Hub agent is narrower than a general coding agent.
|
|
*/
|
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
import { tool } from "ai";
|
|
import { z } from "zod";
|
|
import type { ToolContext, ToolDefinition } from "./tools.js";
|
|
|
|
/** Thrown when a requested path escapes the project workspace root. */
|
|
export class PathEscape extends Error {
|
|
constructor(readonly requested: string, readonly workspaceDir: string) {
|
|
super(`path escapes workspace: ${requested} (root ${workspaceDir})`);
|
|
this.name = "PathEscape";
|
|
}
|
|
}
|
|
|
|
/** Resolve a tool-supplied path against the workspace root, rejecting escapes. */
|
|
function confine(requestedPath: string, workspaceDir: string): string {
|
|
if (isAbsolute(requestedPath)) {
|
|
// Allow absolute paths only if they're already inside the workspace.
|
|
const rel = relative(workspaceDir, requestedPath);
|
|
if (rel.startsWith("..") || rel === "") {
|
|
throw new PathEscape(requestedPath, workspaceDir);
|
|
}
|
|
return requestedPath;
|
|
}
|
|
const resolved = resolve(workspaceDir, requestedPath);
|
|
const rel = relative(workspaceDir, resolved);
|
|
if (rel.startsWith("..")) {
|
|
throw new PathEscape(requestedPath, workspaceDir);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export function readFileTool(ctx: ToolContext): ToolDefinition {
|
|
return tool({
|
|
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
|
inputSchema: z.object({
|
|
path: z.string().describe("Relative path within the workspace."),
|
|
}),
|
|
execute: async (args): Promise<string> => {
|
|
try {
|
|
const full = confine(args.path, ctx.workspaceDir);
|
|
return await readFile(full, "utf8");
|
|
} catch (e) {
|
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
|
return tool({
|
|
description:
|
|
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
|
inputSchema: z.object({
|
|
path: z.string().describe("Relative path within the workspace."),
|
|
content: z.string().describe("The full file content to write."),
|
|
}),
|
|
execute: async (args): Promise<string> => {
|
|
try {
|
|
const full = confine(args.path, ctx.workspaceDir);
|
|
// A-3 file-change capture: compute before/after hashes around the write.
|
|
// A hashing failure must not block the write — skip the sink in that case.
|
|
let beforeHash: string | null = null;
|
|
let afterHash: string | null = null;
|
|
let operation: "CREATE" | "UPDATE" | null = null;
|
|
try {
|
|
let oldContent: string | null;
|
|
try {
|
|
oldContent = await readFile(full, "utf8");
|
|
} catch (e) {
|
|
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
|
|
oldContent = null;
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
afterHash = createHash("sha256").update(args.content).digest("hex");
|
|
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
|
|
if (beforeHash === null) {
|
|
operation = "CREATE";
|
|
} else if (beforeHash === afterHash) {
|
|
operation = null;
|
|
} else {
|
|
operation = "UPDATE";
|
|
}
|
|
} catch {
|
|
operation = null;
|
|
}
|
|
await mkdir(join(full, ".."), { recursive: true });
|
|
await writeFile(full, args.content, "utf8");
|
|
if (operation !== null && ctx.onFileChange !== undefined) {
|
|
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
|
|
}
|
|
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
|
} catch (e) {
|
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
interface Entry {
|
|
readonly name: string;
|
|
readonly kind: "file" | "dir";
|
|
}
|
|
|
|
export function listFilesTool(ctx: ToolContext): ToolDefinition {
|
|
return tool({
|
|
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
|
inputSchema: z.object({
|
|
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
|
|
}),
|
|
execute: async (args): Promise<string> => {
|
|
const sub = args.path ?? ".";
|
|
try {
|
|
const full = confine(sub, ctx.workspaceDir);
|
|
const entries = await readdir(full, { withFileTypes: true });
|
|
const result: Entry[] = entries.map((e) => ({
|
|
name: e.name,
|
|
kind: e.isDirectory() ? "dir" : "file",
|
|
}));
|
|
return JSON.stringify(result);
|
|
} catch (e) {
|
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
},
|
|
});
|
|
}
|