Files
curriculum-project-hub/hub/src/agent/workspace.ts
T
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
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 干净)。
2026-07-07 22:20:30 +08:00

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) });
}
},
});
}