diff --git a/bun.lock b/bun.lock index 1acefd89..318186c5 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@deepagent-code/app", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@codemirror/autocomplete": "6", "@codemirror/commands": "6", @@ -129,7 +129,7 @@ }, "packages/core": { "name": "@deepagent-code/core", - "version": "4.0.6", + "version": "4.0.7-r2", "bin": { "deepagent-code": "./bin/deepagent-code", }, @@ -347,7 +347,7 @@ }, "packages/desktop": { "name": "@deepagent-code/desktop", - "version": "1.4.5", + "version": "1.4.6", "dependencies": { "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", diff --git a/packages/app/package.json b/packages/app/package.json index 639a9949..b143030a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@deepagent-code/app", - "version": "1.4.5", + "version": "1.4.6", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/review/dialog-review-contract.test.ts b/packages/app/src/components/review/dialog-review-contract.test.ts index 0a3b509b..049fb47f 100644 --- a/packages/app/src/components/review/dialog-review-contract.test.ts +++ b/packages/app/src/components/review/dialog-review-contract.test.ts @@ -4,8 +4,8 @@ import { listPending, reviewSummary, setStatus, listEnvFacts, decideEnvFact, mod // P1-C route contract: the V3.1 self-learning Review dialog talks to the raw-request escape-hatch // routes (NOT the generated SDK). These assertions lock the exact method/url/body so a backend // rename of /deepagent/knowledge/{pending,approve,reject-ids} or a payload shape change breaks CI -// here instead of silently shipping a dead Review UI. Mirrors the backend group schema -// (DeepAgentKnowledgeStatusInput = { ids: string[] }). +// here instead of silently shipping a dead Review UI. Review decisions must round-trip the exact +// immutable authority returned by the list endpoint; a bare id can alias project/global revisions. type Recorded = { method: string; url: string; body?: unknown; headers?: Record } function client(calls: Recorded[], data: unknown) { @@ -24,7 +24,13 @@ describe("DeepAgent review dialog route contract", () => { const calls: Recorded[] = [] const items = [ { + sourceStore: "project" as const, id: "memory:1", + version: 3, + hash: "hash-3", + candidateId: "candidate-3", + fingerprint: "fingerprint-3", + governanceRevision: "governance-3", type: "memory" as const, summary: "s", evidence_strength: "strong" as const, @@ -49,29 +55,73 @@ describe("DeepAgent review dialog route contract", () => { expect(calls).toEqual([{ method: "GET", url: "/deepagent/knowledge/review-summary" }]) }) - test("approve POSTs /deepagent/knowledge/approve with { ids }", async () => { + test("approve POSTs the exact listed authority to /deepagent/knowledge/approve", async () => { const calls: Recorded[] = [] - await setStatus(client(calls, { updated: ["a"] }), "approve", ["a", "b"]) + const item = { + sourceStore: "project" as const, + id: "a", + version: 2, + hash: "hash-a-2", + candidateId: "candidate-a", + fingerprint: "fingerprint-a", + governanceRevision: "governance-a-2", + type: "knowledge" as const, + summary: "A", + evidence_strength: "strong" as const, + evidence_refs: [], + approval_status: "pending" as const, + } + await setStatus(client(calls, { updated: item }), "approve", item) expect(calls).toEqual([ { method: "POST", url: "/deepagent/knowledge/approve", - body: { ids: ["a", "b"] }, + body: { + sourceStore: "project", + id: "a", + version: 2, + hash: "hash-a-2", + candidateId: "candidate-a", + fingerprint: "fingerprint-a", + expectedGovernanceRevision: "governance-a-2", + }, headers: { "Content-Type": "application/json" }, }, ]) }) - test("reject POSTs /deepagent/knowledge/reject-ids with { ids }", async () => { + test("reject POSTs the exact global authority to /deepagent/knowledge/reject-ids", async () => { const calls: Recorded[] = [] - await setStatus(client(calls, { updated: ["a"] }), "reject-ids", ["a"]) + const item = { + sourceStore: "user_global" as const, + id: "a", + version: 4, + hash: "hash-a-4", + candidateId: "candidate-a", + fingerprint: "fingerprint-a", + governanceRevision: "governance-a-4", + type: "memory" as const, + summary: "A", + evidence_strength: "medium" as const, + evidence_refs: [], + approval_status: "pending" as const, + } + await setStatus(client(calls, { updated: item }), "reject-ids", item) expect(calls).toEqual([ { method: "POST", url: "/deepagent/knowledge/reject-ids", - body: { ids: ["a"] }, + body: { + sourceStore: "user_global", + id: "a", + version: 4, + hash: "hash-a-4", + candidateId: "candidate-a", + fingerprint: "fingerprint-a", + expectedGovernanceRevision: "governance-a-4", + }, headers: { "Content-Type": "application/json" }, }, ]) diff --git a/packages/app/src/components/review/dialog-review.api.ts b/packages/app/src/components/review/dialog-review.api.ts index 169ef00f..1a99c2e2 100644 --- a/packages/app/src/components/review/dialog-review.api.ts +++ b/packages/app/src/components/review/dialog-review.api.ts @@ -4,7 +4,13 @@ // for back-compat. Keep this file free of any solid-js/UI imports. export type KnowledgeItem = { + sourceStore: "user_global" | "project" id: string + version: number + hash: string + candidateId: string + fingerprint: string + governanceRevision: string type: "knowledge" | "strategy" | "methodology" | "memory" | "skill" | "failure_dossier" summary: string evidence_strength: "strong" | "medium" | "weak" | "none" @@ -47,16 +53,35 @@ export const reviewSummary = async (client: ReviewClient): Promise<{ pendingCoun export const setStatus = async ( client: ReviewClient, action: "approve" | "reject-ids", - ids: string[], + item: KnowledgeItem, ): Promise => { - await client.client.request<{ updated: string[] }>({ + await client.client.request<{ updated: KnowledgeItem }>({ method: "POST", url: `/deepagent/knowledge/${action}`, - body: { ids }, + body: { + sourceStore: item.sourceStore, + id: item.id, + version: item.version, + hash: item.hash, + candidateId: item.candidateId, + fingerprint: item.fingerprint, + expectedGovernanceRevision: item.governanceRevision, + }, headers: { "Content-Type": "application/json" }, }) } +export const reviewAuthorityKey = (item: KnowledgeItem) => + JSON.stringify([ + item.sourceStore, + item.id, + item.version, + item.hash, + item.candidateId, + item.fingerprint, + item.governanceRevision, + ]) + // V3.8.1 §G environment-fact use-gate. Provisional user-global environment facts surface here so the // user decides, per project, whether to adopt them (§G.5). Credentials never appear — only secret_ref // pointers. `degraded` marks a fact whose last connection attempt failed (§G.6). diff --git a/packages/app/src/components/review/dialog-review.tsx b/packages/app/src/components/review/dialog-review.tsx index c3c8a165..b34f3a41 100644 --- a/packages/app/src/components/review/dialog-review.tsx +++ b/packages/app/src/components/review/dialog-review.tsx @@ -11,6 +11,7 @@ import { listEnvFacts, decideEnvFact, modifyEnvFact, + reviewAuthorityKey, type KnowledgeItem, type ReviewClient, type EnvFactItem, @@ -25,6 +26,7 @@ export { listEnvFacts, decideEnvFact, modifyEnvFact, + reviewAuthorityKey, type KnowledgeItem, type ReviewClient, type EnvFactBody, @@ -187,33 +189,36 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { setCollapsed(next) } - const toggle = (id: string) => { + const toggle = (item: KnowledgeItem) => { + const id = reviewAuthorityKey(item) const next = new Set(selected()) if (next.has(id)) next.delete(id) else next.add(id) setSelected(next) } - const selectAll = () => setSelected(new Set(pending().map((i) => i.id))) + const selectAll = () => setSelected(new Set(pending().map(reviewAuthorityKey))) const invert = () => { const cur = selected() setSelected( new Set( pending() - .map((i) => i.id) + .map(reviewAuthorityKey) .filter((id) => !cur.has(id)), ), ) } const apply = async (action: "approve" | "reject-ids") => { - const ids = [...selected()] - if (ids.length === 0 || busy()) return + const decisions = (items() ?? []).filter((item) => selected().has(reviewAuthorityKey(item))) + if (decisions.length === 0 || busy()) return setBusy(true) try { - await setStatus(props.client, action, ids) + await Promise.all(decisions.map((item) => setStatus(props.client, action, item))) setSelected(new Set()) await refetch() } catch (error) { + setSelected(new Set()) + await refetch() showToast({ variant: "error", title: language.t("review.title"), @@ -225,14 +230,14 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { } const Row = (item: KnowledgeItem) => { - const checked = createMemo(() => selected().has(item.id)) + const checked = createMemo(() => selected().has(reviewAuthorityKey(item))) return (