Skip to content

Commit 5355ffb

Browse files
committed
feat: persist context feedback atomically
1 parent 1fdc2c6 commit 5355ffb

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { readJsonDiagnostic, updateJsonAtomic } from "../core/atomic-store.js";
2+
import path from "node:path";
3+
import { hashContextText } from "./hash.js";
4+
import { createContextFeedback, type CreateContextFeedbackInput } from "./feedback.js";
5+
import type { ContextFeedback, ContextFeedbackStore } from "./types.js";
6+
7+
const FEEDBACK_DIRECTORY = path.join(".agent-context", "context-registry", "feedback");
8+
9+
export function contextFeedbackStorePath(repository: string): string {
10+
const root = normalizeRepository(repository);
11+
return path.join(root, FEEDBACK_DIRECTORY, `${hashContextText(root.toLowerCase()).slice(0, 32)}.json`);
12+
}
13+
14+
export function recordContextFeedback(input: CreateContextFeedbackInput): ContextFeedback {
15+
const root = normalizeRepository(input.repository);
16+
const feedback = createContextFeedback({ ...input, repository: root });
17+
let result = feedback;
18+
updateJsonAtomic<ContextFeedbackStore>(contextFeedbackStorePath(root), (current) => {
19+
const store = current ?? emptyStore(root);
20+
validateFeedbackStore(store, root);
21+
const existing = store.feedback.find((item) => item.feedbackId === feedback.feedbackId);
22+
if (existing) {
23+
result = existing;
24+
return store;
25+
}
26+
result = feedback;
27+
return {
28+
...store,
29+
revision: store.revision + 1,
30+
feedback: [...store.feedback, feedback].sort(compareFeedback)
31+
};
32+
});
33+
return result;
34+
}
35+
36+
export function readContextFeedback(repository: string): ContextFeedback[] {
37+
return readContextFeedbackStore(repository).feedback;
38+
}
39+
40+
export function readContextFeedbackStore(repository: string): ContextFeedbackStore {
41+
const root = normalizeRepository(repository);
42+
const filePath = contextFeedbackStorePath(root);
43+
const result = readJsonDiagnostic<ContextFeedbackStore>(filePath);
44+
if (result.status === "missing") return emptyStore(root);
45+
if (result.status === "corrupt") throw new Error(`Unable to read Context feedback store ${filePath}: ${result.error}`);
46+
validateFeedbackStore(result.value, root);
47+
return {
48+
...result.value,
49+
feedback: [...result.value.feedback].sort(compareFeedback)
50+
};
51+
}
52+
53+
function emptyStore(repository: string): ContextFeedbackStore {
54+
return { schemaVersion: 1, revision: 0, repository, feedback: [] };
55+
}
56+
57+
function validateFeedbackStore(store: ContextFeedbackStore, repository: string): void {
58+
if (store.schemaVersion !== 1) throw new Error(`Unsupported Context feedback schemaVersion ${String(store.schemaVersion)}.`);
59+
if (!Number.isInteger(store.revision) || store.revision < 0) throw new Error("Context feedback store revision must be non-negative.");
60+
if (path.resolve(store.repository) !== repository) throw new Error("Context feedback store repository does not match the current repository.");
61+
if (!Array.isArray(store.feedback)) throw new Error("Context feedback store feedback must be an array.");
62+
const ids = new Set<string>();
63+
for (const item of store.feedback) {
64+
if (!item || item.schemaVersion !== 1 || typeof item.feedbackId !== "string") throw new Error("Context feedback store contains an invalid feedback record.");
65+
if (ids.has(item.feedbackId)) throw new Error(`Context feedback store contains duplicate feedbackId ${item.feedbackId}.`);
66+
ids.add(item.feedbackId);
67+
}
68+
}
69+
70+
function compareFeedback(left: ContextFeedback, right: ContextFeedback): number {
71+
return left.createdAt.localeCompare(right.createdAt) || left.feedbackId.localeCompare(right.feedbackId);
72+
}
73+
74+
function normalizeRepository(repository: string): string {
75+
if (!repository.trim()) throw new Error("Context feedback repository must not be empty.");
76+
return path.resolve(repository);
77+
}

src/context-registry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from "./source-registry.js";
1414
export * from "./content-reader.js";
1515
export * from "./annotations.js";
1616
export * from "./feedback.js";
17+
export * from "./feedback-store.js";
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import assert from "node:assert/strict";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
import test from "node:test";
6+
import { contextFeedbackStorePath, readContextFeedback, recordContextFeedback } from "../src/context-registry/feedback-store.js";
7+
8+
test("feedback is stored atomically and duplicate metadata is idempotent", () => {
9+
const root = mkdtempSync(path.join(tmpdir(), "opencode-plusplus-feedback-store-"));
10+
try {
11+
const input = {
12+
repository: root,
13+
entryId: "official/payments",
14+
source: "official",
15+
version: "2.0.0",
16+
revision: 4,
17+
target: "entry" as const,
18+
label: "useful" as const
19+
};
20+
const first = recordContextFeedback(input);
21+
const second = recordContextFeedback(input);
22+
assert.equal(second.feedbackId, first.feedbackId);
23+
assert.equal(readContextFeedback(root).length, 1);
24+
assert.equal(existsSync(contextFeedbackStorePath(root)), true);
25+
} finally {
26+
rmSync(root, { recursive: true, force: true });
27+
}
28+
});
29+
30+
test("corrupt feedback store is diagnosed instead of becoming empty", () => {
31+
const root = mkdtempSync(path.join(tmpdir(), "opencode-plusplus-feedback-corrupt-"));
32+
try {
33+
const filePath = contextFeedbackStorePath(root);
34+
mkdirSync(path.dirname(filePath), { recursive: true });
35+
writeFileSync(filePath, "{broken", "utf8");
36+
assert.throws(() => readContextFeedback(root), /Unable to read Context feedback store/);
37+
} finally {
38+
rmSync(root, { recursive: true, force: true });
39+
}
40+
});

0 commit comments

Comments
 (0)