feat(FN-1184): add agent rating persistence and summary APIs
- Add AgentRating, AgentRatingSummary, and AgentRatingInput types and re-export them from @fusion/core - Bump core schema to v14 with an agentRatings table plus agentId/createdAt indexes - Implement AgentStore rating methods for add/list/filter/summary/delete with score validation and rating:added events - Extend database and agent-store tests to cover migration, rating CRUD behavior, filtering, limits, and trend calculation
This commit is contained in:
@@ -1311,6 +1311,196 @@ describe("AgentStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("rating methods", () => {
|
||||||
|
const addSequencedRatings = async (
|
||||||
|
agentId: string,
|
||||||
|
scores: number[],
|
||||||
|
inputOverrides?: Partial<{ category: string; comment: string; runId: string; taskId: string; raterId: string }>,
|
||||||
|
) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ratings = [];
|
||||||
|
for (let i = 0; i < scores.length; i++) {
|
||||||
|
vi.setSystemTime(new Date(base + i * 1000));
|
||||||
|
ratings.push(
|
||||||
|
await store.addRating(agentId, {
|
||||||
|
raterType: "user",
|
||||||
|
score: scores[i],
|
||||||
|
...inputOverrides,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ratings;
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it("addRating creates a rating and emits rating:added", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Rated Agent", role: "executor" });
|
||||||
|
const handler = vi.fn();
|
||||||
|
store.on("rating:added", handler);
|
||||||
|
|
||||||
|
const rating = await store.addRating(agent.id, {
|
||||||
|
raterType: "user",
|
||||||
|
score: 5,
|
||||||
|
comment: "Great run",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rating.id).toMatch(/^rating-[a-f0-9]{8}$/);
|
||||||
|
expect(rating.agentId).toBe(agent.id);
|
||||||
|
expect(rating.raterType).toBe("user");
|
||||||
|
expect(rating.score).toBe(5);
|
||||||
|
expect(rating.comment).toBe("Great run");
|
||||||
|
expect(new Date(rating.createdAt).getTime()).not.toBeNaN();
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
expect(handler).toHaveBeenCalledWith(rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addRating rejects scores outside 1..5", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Validator", role: "reviewer" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.addRating(agent.id, { raterType: "system", score: 0 }),
|
||||||
|
).rejects.toThrow("Rating score must be between 1 and 5");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.addRating(agent.id, { raterType: "system", score: 6 }),
|
||||||
|
).rejects.toThrow("Rating score must be between 1 and 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addRating stores all optional fields", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Optional Fields", role: "executor" });
|
||||||
|
|
||||||
|
const rating = await store.addRating(agent.id, {
|
||||||
|
raterType: "agent",
|
||||||
|
raterId: "agent-rater",
|
||||||
|
score: 4,
|
||||||
|
category: "quality",
|
||||||
|
comment: "Strong implementation",
|
||||||
|
runId: "run-123",
|
||||||
|
taskId: "FN-1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rating.raterId).toBe("agent-rater");
|
||||||
|
expect(rating.category).toBe("quality");
|
||||||
|
expect(rating.comment).toBe("Strong implementation");
|
||||||
|
expect(rating.runId).toBe("run-123");
|
||||||
|
expect(rating.taskId).toBe("FN-1000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatings returns ratings ordered by createdAt desc", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Order Agent", role: "executor" });
|
||||||
|
const created = await addSequencedRatings(agent.id, [2, 3, 5]);
|
||||||
|
|
||||||
|
const ratings = await store.getRatings(agent.id);
|
||||||
|
|
||||||
|
expect(ratings.map((rating) => rating.id)).toEqual([
|
||||||
|
created[2].id,
|
||||||
|
created[1].id,
|
||||||
|
created[0].id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatings applies category filter", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Category Agent", role: "executor" });
|
||||||
|
await store.addRating(agent.id, { raterType: "user", score: 4, category: "quality" });
|
||||||
|
await store.addRating(agent.id, { raterType: "user", score: 2, category: "speed" });
|
||||||
|
await store.addRating(agent.id, { raterType: "user", score: 5, category: "quality" });
|
||||||
|
|
||||||
|
const ratings = await store.getRatings(agent.id, { category: "quality" });
|
||||||
|
|
||||||
|
expect(ratings).toHaveLength(2);
|
||||||
|
expect(ratings.every((rating) => rating.category === "quality")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatings respects the limit option", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Limit Agent", role: "executor" });
|
||||||
|
await addSequencedRatings(agent.id, [1, 2, 3, 4]);
|
||||||
|
|
||||||
|
const ratings = await store.getRatings(agent.id, { limit: 2 });
|
||||||
|
|
||||||
|
expect(ratings).toHaveLength(2);
|
||||||
|
expect(ratings[0].score).toBe(4);
|
||||||
|
expect(ratings[1].score).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatingSummary returns an empty summary when no ratings exist", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Empty Summary", role: "executor" });
|
||||||
|
|
||||||
|
const summary = await store.getRatingSummary(agent.id);
|
||||||
|
|
||||||
|
expect(summary).toEqual({
|
||||||
|
agentId: agent.id,
|
||||||
|
averageScore: 0,
|
||||||
|
totalRatings: 0,
|
||||||
|
categoryAverages: {},
|
||||||
|
recentRatings: [],
|
||||||
|
trend: "insufficient-data",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatingSummary computes averages and categoryAverages", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Summary Agent", role: "executor" });
|
||||||
|
await addSequencedRatings(agent.id, [5], { category: "quality" });
|
||||||
|
await addSequencedRatings(agent.id, [3], { category: "quality" });
|
||||||
|
await addSequencedRatings(agent.id, [4], { category: "speed" });
|
||||||
|
await addSequencedRatings(agent.id, [2]);
|
||||||
|
|
||||||
|
const summary = await store.getRatingSummary(agent.id);
|
||||||
|
|
||||||
|
expect(summary.averageScore).toBe(3.5);
|
||||||
|
expect(summary.totalRatings).toBe(4);
|
||||||
|
expect(summary.categoryAverages).toEqual({
|
||||||
|
quality: 4,
|
||||||
|
speed: 4,
|
||||||
|
});
|
||||||
|
expect(summary.recentRatings).toHaveLength(4);
|
||||||
|
expect(summary.trend).toBe("insufficient-data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatingSummary trend is improving when recent average is higher", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Improving Agent", role: "executor" });
|
||||||
|
await addSequencedRatings(agent.id, [1, 1, 2, 2, 2, 4, 4, 5, 5, 5]);
|
||||||
|
|
||||||
|
const summary = await store.getRatingSummary(agent.id);
|
||||||
|
|
||||||
|
expect(summary.trend).toBe("improving");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatingSummary trend is declining when recent average is lower", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Declining Agent", role: "executor" });
|
||||||
|
await addSequencedRatings(agent.id, [5, 5, 4, 4, 4, 2, 2, 1, 1, 1]);
|
||||||
|
|
||||||
|
const summary = await store.getRatingSummary(agent.id);
|
||||||
|
|
||||||
|
expect(summary.trend).toBe("declining");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRatingSummary trend is stable when windows are approximately equal", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Stable Agent", role: "executor" });
|
||||||
|
await addSequencedRatings(agent.id, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]);
|
||||||
|
|
||||||
|
const summary = await store.getRatingSummary(agent.id);
|
||||||
|
|
||||||
|
expect(summary.trend).toBe("stable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteRating removes the rating", async () => {
|
||||||
|
const agent = await store.createAgent({ name: "Delete Agent", role: "executor" });
|
||||||
|
const first = await store.addRating(agent.id, { raterType: "user", score: 4 });
|
||||||
|
await store.addRating(agent.id, { raterType: "user", score: 5 });
|
||||||
|
|
||||||
|
await store.deleteRating(first.id);
|
||||||
|
|
||||||
|
const ratings = await store.getRatings(agent.id);
|
||||||
|
expect(ratings).toHaveLength(1);
|
||||||
|
expect(ratings[0].id).not.toBe(first.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── heartbeat lifecycle via updateAgentState ──────────────────────
|
// ── heartbeat lifecycle via updateAgentState ──────────────────────
|
||||||
|
|
||||||
describe("heartbeat lifecycle via updateAgentState", () => {
|
describe("heartbeat lifecycle via updateAgentState", () => {
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ import type {
|
|||||||
AgentAccessState,
|
AgentAccessState,
|
||||||
OrgTreeNode,
|
OrgTreeNode,
|
||||||
InstructionsBundleConfig,
|
InstructionsBundleConfig,
|
||||||
|
AgentRating,
|
||||||
|
AgentRatingSummary,
|
||||||
|
AgentRatingInput,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||||
import { computeAccessState } from "./agent-permissions.js";
|
import { computeAccessState } from "./agent-permissions.js";
|
||||||
|
import { Database } from "./db.js";
|
||||||
|
|
||||||
/** Events emitted by AgentStore */
|
/** Events emitted by AgentStore */
|
||||||
export interface AgentStoreEvents {
|
export interface AgentStoreEvents {
|
||||||
@@ -55,6 +59,8 @@ export interface AgentStoreEvents {
|
|||||||
"agent:configRevision": (agentId: string, revision: AgentConfigRevision) => void;
|
"agent:configRevision": (agentId: string, revision: AgentConfigRevision) => void;
|
||||||
/** Emitted when a task is assigned to an agent (taskId is non-empty) */
|
/** Emitted when a task is assigned to an agent (taskId is non-empty) */
|
||||||
"agent:assigned": (agent: Agent, taskId: string) => void;
|
"agent:assigned": (agent: Agent, taskId: string) => void;
|
||||||
|
/** Emitted when a rating is added */
|
||||||
|
"rating:added": (rating: AgentRating) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
|
type TypedEventEmitter<Events extends Record<string, unknown[]>> = {
|
||||||
@@ -108,6 +114,7 @@ export class AgentStore extends EventEmitter {
|
|||||||
private rootDir: string;
|
private rootDir: string;
|
||||||
private agentsDir: string;
|
private agentsDir: string;
|
||||||
private locks: Map<string, AgentLock> = new Map();
|
private locks: Map<string, AgentLock> = new Map();
|
||||||
|
private _db: Database | null = null;
|
||||||
|
|
||||||
constructor(options: AgentStoreOptions = {}) {
|
constructor(options: AgentStoreOptions = {}) {
|
||||||
super();
|
super();
|
||||||
@@ -115,11 +122,20 @@ export class AgentStore extends EventEmitter {
|
|||||||
this.agentsDir = join(this.rootDir, "agents");
|
this.agentsDir = join(this.rootDir, "agents");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get db(): Database {
|
||||||
|
if (!this._db) {
|
||||||
|
this._db = new Database(this.rootDir);
|
||||||
|
this._db.init();
|
||||||
|
}
|
||||||
|
return this._db;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the store by creating necessary directories.
|
* Initialize the store by creating necessary directories.
|
||||||
* Should be called before other operations.
|
* Should be called before other operations.
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
const _ = this.db;
|
||||||
await mkdir(this.agentsDir, { recursive: true });
|
await mkdir(this.agentsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +235,146 @@ export class AgentStore extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapRatingRow(row: any): AgentRating {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
agentId: row.agentId,
|
||||||
|
raterType: row.raterType,
|
||||||
|
raterId: row.raterId ?? undefined,
|
||||||
|
score: row.score,
|
||||||
|
category: row.category ?? undefined,
|
||||||
|
comment: row.comment ?? undefined,
|
||||||
|
runId: row.runId ?? undefined,
|
||||||
|
taskId: row.taskId ?? undefined,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRating(agentId: string, input: AgentRatingInput): Promise<AgentRating> {
|
||||||
|
if (input.score < 1 || input.score > 5) {
|
||||||
|
throw new Error("Rating score must be between 1 and 5");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rating: AgentRating = {
|
||||||
|
id: `rating-${randomUUID().slice(0, 8)}`,
|
||||||
|
agentId,
|
||||||
|
raterType: input.raterType,
|
||||||
|
raterId: input.raterId,
|
||||||
|
score: input.score,
|
||||||
|
category: input.category,
|
||||||
|
comment: input.comment,
|
||||||
|
runId: input.runId,
|
||||||
|
taskId: input.taskId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO agentRatings (id, agentId, raterType, raterId, score, category, comment, runId, taskId, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
rating.id,
|
||||||
|
rating.agentId,
|
||||||
|
rating.raterType,
|
||||||
|
rating.raterId ?? null,
|
||||||
|
rating.score,
|
||||||
|
rating.category ?? null,
|
||||||
|
rating.comment ?? null,
|
||||||
|
rating.runId ?? null,
|
||||||
|
rating.taskId ?? null,
|
||||||
|
rating.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("rating:added", rating);
|
||||||
|
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRatings(agentId: string, options?: { limit?: number; category?: string }): Promise<AgentRating[]> {
|
||||||
|
const params: Array<string | number> = [agentId];
|
||||||
|
let query = "SELECT * FROM agentRatings WHERE agentId = ?";
|
||||||
|
|
||||||
|
if (options?.category !== undefined) {
|
||||||
|
query += " AND category = ?";
|
||||||
|
params.push(options.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY createdAt DESC";
|
||||||
|
|
||||||
|
if (options?.limit !== undefined) {
|
||||||
|
query += " LIMIT ?";
|
||||||
|
params.push(options.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = this.db.prepare(query).all(...params);
|
||||||
|
return rows.map((row) => this.mapRatingRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRatingSummary(agentId: string): Promise<AgentRatingSummary> {
|
||||||
|
const ratings = await this.getRatings(agentId);
|
||||||
|
|
||||||
|
if (ratings.length === 0) {
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
averageScore: 0,
|
||||||
|
totalRatings: 0,
|
||||||
|
categoryAverages: {},
|
||||||
|
recentRatings: [],
|
||||||
|
trend: "insufficient-data",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageScore = Math.round((ratings.reduce((sum, rating) => sum + rating.score, 0) / ratings.length) * 100) / 100;
|
||||||
|
|
||||||
|
const categoryBuckets = new Map<string, { total: number; count: number }>();
|
||||||
|
for (const rating of ratings) {
|
||||||
|
if (rating.category === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = categoryBuckets.get(rating.category) ?? { total: 0, count: 0 };
|
||||||
|
existing.total += rating.score;
|
||||||
|
existing.count += 1;
|
||||||
|
categoryBuckets.set(rating.category, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryAverages: Record<string, number> = {};
|
||||||
|
for (const [category, bucket] of categoryBuckets) {
|
||||||
|
categoryAverages[category] = Math.round((bucket.total / bucket.count) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentRatings = ratings.slice(0, 10);
|
||||||
|
|
||||||
|
let trend: AgentRatingSummary["trend"] = "insufficient-data";
|
||||||
|
if (ratings.length >= 10) {
|
||||||
|
const recentWindow = ratings.slice(0, 5);
|
||||||
|
const previousWindow = ratings.slice(5, 10);
|
||||||
|
const recentAvg = recentWindow.reduce((sum, rating) => sum + rating.score, 0) / recentWindow.length;
|
||||||
|
const previousAvg = previousWindow.reduce((sum, rating) => sum + rating.score, 0) / previousWindow.length;
|
||||||
|
|
||||||
|
if (Math.abs(recentAvg - previousAvg) <= 0.01) {
|
||||||
|
trend = "stable";
|
||||||
|
} else if (recentAvg > previousAvg) {
|
||||||
|
trend = "improving";
|
||||||
|
} else {
|
||||||
|
trend = "declining";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
averageScore,
|
||||||
|
totalRatings: ratings.length,
|
||||||
|
categoryAverages,
|
||||||
|
recentRatings,
|
||||||
|
trend,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRating(ratingId: string): Promise<void> {
|
||||||
|
this.db.prepare("DELETE FROM agentRatings WHERE id = ?").run(ratingId);
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the managed instructions directory path for an agent.
|
* Get the managed instructions directory path for an agent.
|
||||||
* Does not create the directory.
|
* Does not create the directory.
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe("Database", () => {
|
|||||||
expect(tableNames).toContain("mission_features");
|
expect(tableNames).toContain("mission_features");
|
||||||
expect(tableNames).toContain("ai_sessions");
|
expect(tableNames).toContain("ai_sessions");
|
||||||
expect(tableNames).toContain("messages");
|
expect(tableNames).toContain("messages");
|
||||||
|
expect(tableNames).toContain("agentRatings");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates all expected indexes", () => {
|
it("creates all expected indexes", () => {
|
||||||
@@ -90,10 +91,12 @@ describe("Database", () => {
|
|||||||
expect(indexNames).toContain("idxMessagesCreatedAt");
|
expect(indexNames).toContain("idxMessagesCreatedAt");
|
||||||
expect(indexNames).toContain("idxMessagesFrom");
|
expect(indexNames).toContain("idxMessagesFrom");
|
||||||
expect(indexNames).toContain("idxMessagesTo");
|
expect(indexNames).toContain("idxMessagesTo");
|
||||||
|
expect(indexNames).toContain("idxAgentRatingsAgentId");
|
||||||
|
expect(indexNames).toContain("idxAgentRatingsCreatedAt");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -116,7 +119,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -723,7 +726,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -748,11 +751,35 @@ describe("schema migrations", () => {
|
|||||||
const db = new Database(kbDir);
|
const db = new Database(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies migration 14 by creating agentRatings table and indexes", () => {
|
||||||
|
tmpDir = makeTmpDir();
|
||||||
|
const kbDir = join(tmpDir, ".fusion");
|
||||||
|
|
||||||
|
const db = new Database(kbDir);
|
||||||
|
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||||
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '13')");
|
||||||
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||||
|
|
||||||
|
db.init();
|
||||||
|
|
||||||
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
|
|
||||||
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||||
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
|
|
||||||
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = 'agentRatings' ORDER BY name").all() as Array<{ name: string }>;
|
||||||
|
const indexNames = indexes.map((index) => index.name);
|
||||||
|
expect(indexNames).toContain("idxAgentRatingsAgentId");
|
||||||
|
expect(indexNames).toContain("idxAgentRatingsCreatedAt");
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -847,7 +874,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5
|
// Verify version bumped to 5
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1057,7 +1084,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(kbDir);
|
const db = createDatabase(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(13);
|
expect(db.getSchemaVersion()).toBe(14);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 13;
|
const SCHEMA_VERSION = 14;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
steeringComments: SteeringComment[] | undefined,
|
||||||
@@ -451,9 +451,6 @@ export class Database {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future migrations go here:
|
|
||||||
// if (version < 14) { this.applyMigration(14, () => { ... }); }
|
|
||||||
|
|
||||||
if (version < 10) {
|
if (version < 10) {
|
||||||
this.applyMigration(10, () => {
|
this.applyMigration(10, () => {
|
||||||
this.addColumnIfMissing("missions", "autopilotEnabled", "INTEGER DEFAULT 0");
|
this.addColumnIfMissing("missions", "autopilotEnabled", "INTEGER DEFAULT 0");
|
||||||
@@ -498,6 +495,27 @@ export class Database {
|
|||||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksAssignedAgentId ON tasks(assignedAgentId)`);
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTasksAssignedAgentId ON tasks(assignedAgentId)`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 14) {
|
||||||
|
this.applyMigration(14, () => {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agentRatings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
agentId TEXT NOT NULL,
|
||||||
|
raterType TEXT NOT NULL,
|
||||||
|
raterId TEXT,
|
||||||
|
score INTEGER NOT NULL CHECK(score BETWEEN 1 AND 5),
|
||||||
|
category TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
runId TEXT,
|
||||||
|
taskId TEXT,
|
||||||
|
createdAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAgentRatingsAgentId ON agentRatings(agentId)`);
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAgentRatingsCreatedAt ON agentRatings(createdAt)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
|
|||||||
@@ -1728,6 +1728,41 @@ export interface AgentTaskSession {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single performance rating for an agent */
|
||||||
|
export interface AgentRating {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
raterType: "user" | "agent" | "system";
|
||||||
|
raterId?: string;
|
||||||
|
score: number;
|
||||||
|
category?: string;
|
||||||
|
comment?: string;
|
||||||
|
runId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated rating statistics for an agent */
|
||||||
|
export interface AgentRatingSummary {
|
||||||
|
agentId: string;
|
||||||
|
averageScore: number;
|
||||||
|
totalRatings: number;
|
||||||
|
categoryAverages: Record<string, number>;
|
||||||
|
recentRatings: AgentRating[];
|
||||||
|
trend: "improving" | "declining" | "stable" | "insufficient-data";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input payload for creating an agent rating */
|
||||||
|
export interface AgentRatingInput {
|
||||||
|
raterType: "user" | "agent" | "system";
|
||||||
|
raterId?: string;
|
||||||
|
score: number;
|
||||||
|
category?: string;
|
||||||
|
comment?: string;
|
||||||
|
runId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Trackable configuration fields for revision history.
|
/** Trackable configuration fields for revision history.
|
||||||
* Excludes budget-related items, state, taskId, token counts, and timestamps. */
|
* Excludes budget-related items, state, taskId, token counts, and timestamps. */
|
||||||
export interface AgentConfigSnapshot {
|
export interface AgentConfigSnapshot {
|
||||||
|
|||||||
Reference in New Issue
Block a user