diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 82b5e326..83b3189e 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -29,6 +29,7 @@ import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; import { ConnectorGrantsService } from "../services/composio/connector-grants"; +import { approvalDrifts } from "../services/approval-drift"; import { IntegrationKeyStore } from "../services/integration-key-store"; import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills"; import { coalesce } from "./coalesce"; @@ -417,6 +418,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input))); + ipcMain.handle(IPC_CHANNELS.approvalDrifts, (_event, input) => { + try { + return ok(approvalDrifts(deps.connection.db, input.orgId)); + } catch (error) { + return fail("approval_drift_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).revoke(input))); ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => { try { diff --git a/src/main/services/approval-drift.ts b/src/main/services/approval-drift.ts new file mode 100644 index 00000000..9f113c84 --- /dev/null +++ b/src/main/services/approval-drift.ts @@ -0,0 +1,32 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import type { DatabaseSync } from "node:sqlite"; +import { parseApprovalPin, type ApprovalDrift, type ApprovalPin } from "../../shared/approval-pin"; + +const CHECKED_APPROVALS = 24; + +/** Approved deliverables whose content no longer matches what was approved, read from disk and the library now. */ +export function approvalDrifts(db: DatabaseSync, orgId: string): Record { + const rows = db.prepare(` + select id, subject_snapshot from approvals + where org_id = ? and state = 'approved' and kind = 'deliverable_review' and subject_snapshot is not null + order by decided_at desc limit ? + `).all(orgId, CHECKED_APPROVALS) as Array<{ id: string; subject_snapshot: string }>; + const drifts: Record = {}; + for (const row of rows) { + const pin = parseApprovalPin(row.subject_snapshot); + const drift = pin ? driftOf(db, orgId, pin) : null; + if (drift) drifts[row.id] = drift; + } + return drifts; +} + +function driftOf(db: DatabaseSync, orgId: string, pin: ApprovalPin): ApprovalDrift | null { + if (pin.kind === "file") { + if (!existsSync(pin.storagePath)) return "removed"; + return createHash("sha256").update(readFileSync(pin.storagePath)).digest("hex") === pin.sha256 ? null : "changed"; + } + const document = db.prepare("select version from documents where id = ? and org_id = ?").get(pin.documentId, orgId) as { version: number } | undefined; + if (!document) return "removed"; + return document.version === pin.version ? null : "changed"; +} diff --git a/src/main/services/held-deliverables.ts b/src/main/services/held-deliverables.ts index 10060015..bf70c408 100644 --- a/src/main/services/held-deliverables.ts +++ b/src/main/services/held-deliverables.ts @@ -1,5 +1,7 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; +import type { ApprovalPin } from "../../shared/approval-pin"; import { writeDeliverable } from "./deliverable-writer"; import { DocumentService } from "./document-service"; import { recordWorkspaceFile } from "./files/file-object-store"; @@ -14,6 +16,7 @@ export type HeldTarget = interface HeldRow { id: string; org_id: string; + approval_id: string; kind: HeldTarget["kind"]; agent_id: string | null; task_id: string | null; @@ -79,6 +82,8 @@ export function settleHeldDeliverable(db: DatabaseSync, heldId: string, approved function publish(db: DatabaseSync, row: HeldRow): void { if (row.kind === "file") { const { absolute } = resolveWorkspacePath(row.workspace_root!, row.relative_path!); + // The file's pin is known before it is written, so it is recorded first and the disk write stays last. + pinApproval(db, row, { kind: "file", storagePath: absolute, sha256: createHash("sha256").update(row.content, "utf8").digest("hex") }); const previous = existsSync(absolute) ? readFileSync(absolute) : null; try { const written = writeDeliverable({ workspaceRoot: row.workspace_root!, relativePath: row.relative_path!, content: row.content }); @@ -93,10 +98,11 @@ function publish(db: DatabaseSync, row: HeldRow): void { } const documents = new DocumentService(db); if (row.kind === "document_create") { - documents.create({ + const documentId = documents.create({ orgId: row.org_id, title: row.title!, docType: row.doc_type!, content: row.content, authorAgentId: row.agent_id, reviewerAgentId: null, projectId: row.project_id, goalId: row.goal_id, taskId: row.task_id, }); + pinApproval(db, row, { kind: "document", documentId, version: 1 }); return; } const current = db.prepare("select version, status from documents where id = ? and org_id = ?").get(row.document_id, row.org_id) as { version: number; status: string } | undefined; @@ -104,10 +110,16 @@ function publish(db: DatabaseSync, row: HeldRow): void { if (current.version !== row.base_version) { throw new Error(`The document moved from v${row.base_version} to v${current.version} after this draft was held; decline the review and ask for a fresh draft.`); } - documents.update({ + const version = documents.update({ orgId: row.org_id, documentId: row.document_id!, title: row.title!, content: row.content, status: current.status, reason: row.reason!, changedByType: "agent", changedById: row.agent_id!, }); + pinApproval(db, row, { kind: "document", documentId: row.document_id!, version }); +} + +/** Records on the approval exactly what it approved, so later changes in place show against it. */ +function pinApproval(db: DatabaseSync, row: HeldRow, pin: ApprovalPin): void { + db.prepare("update approvals set subject_snapshot = ? where id = ?").run(JSON.stringify(pin), row.approval_id); } /** Held work of a task or goal still waiting for review, by title; done and achieved wait for it. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index d6923453..89d4a30f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -93,6 +93,7 @@ const api: DesktopApi = { connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), disconnectComposioToolkit: (input) => invoke(IPC_CHANNELS.disconnectComposioToolkit, input), grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input), + approvalDrifts: (input) => invoke(IPC_CHANNELS.approvalDrifts, input), revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 7d2c0f9e..4710ec93 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -388,6 +388,9 @@ export const zh: Record = { "Add account": "添加账号", "limited": "受限", "Replaced by an edit": "已因编辑被替换", + "Changed since approval": "批准后已改动", + "Removed since approval": "批准后已删除", + "Could not check approved content: {message}": "无法核对已批准内容:{message}", "Revoke {name}": "撤销 {name}", "Grant {name} to": "把 {name} 授予", "Grant to…": "授予给…", diff --git a/src/renderer/src/pages/ApprovalsPage.tsx b/src/renderer/src/pages/ApprovalsPage.tsx index 920e504e..cf0e1670 100644 --- a/src/renderer/src/pages/ApprovalsPage.tsx +++ b/src/renderer/src/pages/ApprovalsPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Avatar, Button, Icon, IconButton, Pill } from "../shared/ui"; import { TopbarActions } from "../shared/TopbarActions"; import type { @@ -7,6 +7,7 @@ import type { EmployeeRecord, RiskLevel, } from "../../../shared/domain"; +import type { ApprovalDrift } from "../../../shared/approval-pin"; import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId, formatRelative, requireActiveEmployee } from "./types"; @@ -47,6 +48,25 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) { const editing = editingId ? approvals.find((approval) => approval.id === editingId) ?? null : null; + // Drift is read from disk in the main process, again whenever the approvals change. + const [drifts, setDrifts] = useState>({}); + const [driftError, setDriftError] = useState(null); + useEffect(() => { + let cancelled = false; + void window.agentCompany.approvalDrifts({ orgId }).then((result) => { + if (cancelled) return; + if (result.ok) { + setDrifts(result.data); + setDriftError(null); + } else { + setDriftError(result.error.message); + } + }); + return () => { + cancelled = true; + }; + }, [orgId, approvals]); + const decide = (approval: ApprovalRecord, state: "approved" | "declined", notes?: string) => { setEditingId(null); void actions.run(() => window.agentCompany.decideApproval({ @@ -97,11 +117,13 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) { {decided.length > 0 && ( <>
{t("Recently decided")}
+ {driftError &&
{t("Could not check approved content: {message}", { message: driftError })}
}
{decided.slice(0, 24).map((approval, index) => (
{approval.title}
+ {drift && {drift === "changed" ? t("Changed since approval") : t("Removed since approval")}}
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)}{superseded ? "" : ` · ${decider.split(" ")[0]}`} diff --git a/src/shared/approval-pin.ts b/src/shared/approval-pin.ts new file mode 100644 index 00000000..82a92922 --- /dev/null +++ b/src/shared/approval-pin.ts @@ -0,0 +1,15 @@ +/** Exactly what an approved deliverable was when the board approved it. */ +export type ApprovalPin = + | { kind: "file"; storagePath: string; sha256: string } + | { kind: "document"; documentId: string; version: number }; + +/** How an approved deliverable stands now against its pin. */ +export type ApprovalDrift = "changed" | "removed"; + +/** The pin of an approval, or null when it carries none (task approvals keep a task snapshot instead). */ +export function parseApprovalPin(subjectSnapshot: string | null): ApprovalPin | null { + if (!subjectSnapshot) return null; + const value = JSON.parse(subjectSnapshot) as Partial & { kind?: string }; + if (value.kind === "file" || value.kind === "document") return value as ApprovalPin; + return null; +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 03df8d99..8d7e87e1 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -1,3 +1,4 @@ +import type { ApprovalDrift } from "./approval-pin"; import type { DesktopResult } from "./result"; import type { AppSnapshot, SearchResultRecord } from "./domain"; import type { ProviderModelEntry } from "./provider-models"; @@ -203,6 +204,8 @@ export interface DesktopApi { disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise>; refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise>; grantConnector(input: GrantConnectorInput): Promise>; + /** Approved deliverables whose content changed or vanished since approval, checked on disk now. */ + approvalDrifts(input: { orgId: string }): Promise>>; revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 40ea0c37..c9e68d97 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -179,6 +179,8 @@ export interface ApprovalRecord { requestedById: string | null; sourceEntityType: string | null; sourceEntityId: string | null; + /** JSON of what the approval covers: a task snapshot, or the pin of an approved deliverable. */ + subjectSnapshot: string | null; state: ApprovalState; decisionNotes: string | null; decidedByEmployeeId: string | null; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 887dfb44..8d14bd5f 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -86,6 +86,7 @@ export const IPC_CHANNELS = { disconnectComposioToolkit: "agentcompany:disconnect-composio-toolkit", refreshComposioAccounts: "agentcompany:refresh-composio-accounts", grantConnector: "agentcompany:grant-connector", + approvalDrifts: "agentcompany:approval-drifts", revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index 3681afa9..c1e98e0c 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -9,6 +9,8 @@ import { ChatService } from "../../src/main/services/chat-service"; import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; import { ApprovalService } from "../../src/main/services/approval-service"; import { runJournalLines } from "../../src/main/services/run-journal"; +import { DocumentService } from "../../src/main/services/document-service"; +import { approvalDrifts } from "../../src/main/services/approval-drift"; import { executionApprovalProblem, requireAssignedAgent } from "../../src/main/services/task-run-preparation"; import { TASK_PRIORITY } from "../../src/shared/domain"; import type { TaskExecutionTaskRow } from "../../src/main/services/task-prompts"; @@ -94,6 +96,16 @@ describe("execution modes", () => { } expect(readFileSync(join(root, "plan", "business-plan.md"), "utf8")).toBe("# Plan"); expect(db.prepare("select title, content from documents where org_id = ?").all(h.orgId)).toEqual([{ title: "Launch memo", content: "Ship it." }]); + + // Each approval pins what was approved; content changed or removed later, even outside the app, shows against it. + expect(approvalDrifts(db, h.orgId)).toEqual({}); + const [fileReview, docReview] = reviews.map((review) => review.id); + writeFileSync(join(root, "plan", "business-plan.md"), "# Edited by hand"); + expect(approvalDrifts(db, h.orgId)).toEqual({ [fileReview]: "changed" }); + rmSync(join(root, "plan", "business-plan.md")); + const docId = (db.prepare("select id from documents where org_id = ?").get(h.orgId) as { id: string }).id; + h.connection.transaction(() => new DocumentService(db).update({ orgId: h.orgId, documentId: docId, title: "Launch memo", content: "Ship it later.", status: "draft", reason: "edit", changedByType: "employee", changedById: employeeId })); + expect(approvalDrifts(db, h.orgId)).toEqual({ [fileReview]: "removed", [docReview]: "changed" }); }); test("a declined deliverable is never written, and held work keeps its task open", () => {