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 干净)。
This commit is contained in:
2026-07-07 22:20:30 +08:00
parent f8c3e16a52
commit e7924f78d5
8 changed files with 280 additions and 7 deletions
@@ -0,0 +1,45 @@
-- AgentMessage: per-message record of the agent loop (queryable projection
-- alongside the transcript JSONL file, which remains the agent's read-back memory).
CREATE TABLE "AgentMessage" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"runId" TEXT,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachments" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentMessage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentMessage_sessionId_createdAt_idx" ON "AgentMessage"("sessionId", "createdAt");
CREATE INDEX "AgentMessage_runId_idx" ON "AgentMessage"("runId");
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AgentFileChange: file change captured during a run (write/rename/delete).
CREATE TABLE "AgentFileChange" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"path" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"beforeHash" TEXT,
"afterHash" TEXT,
"diff" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentFileChange_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "AgentFileChange_runId_idx" ON "AgentFileChange"("runId");
CREATE INDEX "AgentFileChange_projectId_idx" ON "AgentFileChange"("projectId");
CREATE INDEX "AgentFileChange_path_idx" ON "AgentFileChange"("path");
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProjectAgentLock: add heartbeatAt for liveness (A-5).
ALTER TABLE "ProjectAgentLock" ADD COLUMN "heartbeatAt" TIMESTAMP(3);
+55 -4
View File
@@ -84,6 +84,7 @@ model Project {
permissionSettings PermissionSettings[] @relation("projectSettings")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([archivedAt])
}
@@ -125,6 +126,7 @@ model AgentSession {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt])
@@index([provider, model])
@@ -172,6 +174,8 @@ model AgentRun {
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@index([projectId, status])
@@index([sessionId])
@@ -184,11 +188,12 @@ model AgentRun {
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
/// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock {
projectId String @id
runId String @unique
projectId String @id
runId String @unique
holderUserId String?
acquiredAt DateTime @default(now())
expiresAt DateTime?
acquiredAt DateTime @default(now())
heartbeatAt DateTime?
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
@@ -331,3 +336,49 @@ model FeishuEventReceipt {
@@index([messageId])
@@index([receivedAt])
}
// --- Structured run history (ADR-0003 Memory) ----------------------------
/// Per-message record of the agent loop. The transcript JSONL file remains the
/// agent's own read-back memory (runner loads it to seed the next run); these
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
/// session messages ≠ Feishu group chat history.
model AgentMessage {
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
attachments Json
createdAt DateTime @default(now())
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
@@index([sessionId, createdAt])
@@index([runId])
}
/// File change captured during a run (writeFile/rename/delete in workspace).
/// before/after hash + diff for audit and admin review. Linked to the run
/// that produced it; the tool's execute closure records via a sink the runner
/// injects into ToolContext (A-3 file-change capture).
model AgentFileChange {
id String @id @default(cuid())
runId String
projectId String
path String
operation String
beforeHash String?
afterHash String?
diff String?
metadata Json
createdAt DateTime @default(now())
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
@@index([runId])
@@index([projectId])
@@index([path])
}