Q110: an approval pins the content it approved, and later changes show against it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e90b15a7a2
commit
5aab82b7c3
11 files changed
+118
-4
No files matched your search
@@ -29,6 +29,7 @@ import { MeetingService } from "../services/meeting-service";
|
|||||||
import { SprintService } from "../services/sprint-service";
|
import { SprintService } from "../services/sprint-service";
|
||||||
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
|
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
|
||||||
import { ConnectorGrantsService } from "../services/composio/connector-grants";
|
import { ConnectorGrantsService } from "../services/composio/connector-grants";
|
||||||
|
import { approvalDrifts } from "../services/approval-drift";
|
||||||
import { IntegrationKeyStore } from "../services/integration-key-store";
|
import { IntegrationKeyStore } from "../services/integration-key-store";
|
||||||
import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills";
|
import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills";
|
||||||
import { coalesce } from "./coalesce";
|
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.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.revokeConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).revoke(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => {
|
ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<string, ApprovalDrift> {
|
||||||
|
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<string, ApprovalDrift> = {};
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { ApprovalPin } from "../../shared/approval-pin";
|
||||||
import { writeDeliverable } from "./deliverable-writer";
|
import { writeDeliverable } from "./deliverable-writer";
|
||||||
import { DocumentService } from "./document-service";
|
import { DocumentService } from "./document-service";
|
||||||
import { recordWorkspaceFile } from "./files/file-object-store";
|
import { recordWorkspaceFile } from "./files/file-object-store";
|
||||||
@@ -14,6 +16,7 @@ export type HeldTarget =
|
|||||||
interface HeldRow {
|
interface HeldRow {
|
||||||
id: string;
|
id: string;
|
||||||
org_id: string;
|
org_id: string;
|
||||||
|
approval_id: string;
|
||||||
kind: HeldTarget["kind"];
|
kind: HeldTarget["kind"];
|
||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
task_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 {
|
function publish(db: DatabaseSync, row: HeldRow): void {
|
||||||
if (row.kind === "file") {
|
if (row.kind === "file") {
|
||||||
const { absolute } = resolveWorkspacePath(row.workspace_root!, row.relative_path!);
|
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;
|
const previous = existsSync(absolute) ? readFileSync(absolute) : null;
|
||||||
try {
|
try {
|
||||||
const written = writeDeliverable({ workspaceRoot: row.workspace_root!, relativePath: row.relative_path!, content: row.content });
|
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);
|
const documents = new DocumentService(db);
|
||||||
if (row.kind === "document_create") {
|
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,
|
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,
|
reviewerAgentId: null, projectId: row.project_id, goalId: row.goal_id, taskId: row.task_id,
|
||||||
});
|
});
|
||||||
|
pinApproval(db, row, { kind: "document", documentId, version: 1 });
|
||||||
return;
|
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;
|
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) {
|
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.`);
|
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,
|
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!,
|
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. */
|
/** Held work of a task or goal still waiting for review, by title; done and achieved wait for it. */
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const api: DesktopApi = {
|
|||||||
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
|
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
|
||||||
disconnectComposioToolkit: (input) => invoke(IPC_CHANNELS.disconnectComposioToolkit, input),
|
disconnectComposioToolkit: (input) => invoke(IPC_CHANNELS.disconnectComposioToolkit, input),
|
||||||
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
|
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
|
||||||
|
approvalDrifts: (input) => invoke(IPC_CHANNELS.approvalDrifts, input),
|
||||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||||
|
|||||||
@@ -388,6 +388,9 @@ export const zh: Record<string, string> = {
|
|||||||
"Add account": "添加账号",
|
"Add account": "添加账号",
|
||||||
"limited": "受限",
|
"limited": "受限",
|
||||||
"Replaced by an edit": "已因编辑被替换",
|
"Replaced by an edit": "已因编辑被替换",
|
||||||
|
"Changed since approval": "批准后已改动",
|
||||||
|
"Removed since approval": "批准后已删除",
|
||||||
|
"Could not check approved content: {message}": "无法核对已批准内容:{message}",
|
||||||
"Revoke {name}": "撤销 {name}",
|
"Revoke {name}": "撤销 {name}",
|
||||||
"Grant {name} to": "把 {name} 授予",
|
"Grant {name} to": "把 {name} 授予",
|
||||||
"Grant to…": "授予给…",
|
"Grant to…": "授予给…",
|
||||||
|
|||||||
@@ -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 { Avatar, Button, Icon, IconButton, Pill } from "../shared/ui";
|
||||||
import { TopbarActions } from "../shared/TopbarActions";
|
import { TopbarActions } from "../shared/TopbarActions";
|
||||||
import type {
|
import type {
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
EmployeeRecord,
|
EmployeeRecord,
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
} from "../../../shared/domain";
|
} from "../../../shared/domain";
|
||||||
|
import type { ApprovalDrift } from "../../../shared/approval-pin";
|
||||||
import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain";
|
import { APPROVAL_STATE, RISK_LEVEL } from "../../../shared/domain";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId, formatRelative, requireActiveEmployee } 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;
|
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<Record<string, ApprovalDrift>>({});
|
||||||
|
const [driftError, setDriftError] = useState<string | null>(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) => {
|
const decide = (approval: ApprovalRecord, state: "approved" | "declined", notes?: string) => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
void actions.run(() => window.agentCompany.decideApproval({
|
void actions.run(() => window.agentCompany.decideApproval({
|
||||||
@@ -97,11 +117,13 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
|
|||||||
{decided.length > 0 && (
|
{decided.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="section-label" style={{ margin: "32px 0 10px" }}>{t("Recently decided")}</div>
|
<div className="section-label" style={{ margin: "32px 0 10px" }}>{t("Recently decided")}</div>
|
||||||
|
{driftError && <div style={{ color: "var(--red)", fontSize: 13, marginBottom: 8 }}>{t("Could not check approved content: {message}", { message: driftError })}</div>}
|
||||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||||
{decided.slice(0, 24).map((approval, index) => (
|
{decided.slice(0, 24).map((approval, index) => (
|
||||||
<DecidedRow
|
<DecidedRow
|
||||||
key={approval.id}
|
key={approval.id}
|
||||||
approval={approval}
|
approval={approval}
|
||||||
|
drift={drifts[approval.id] ?? null}
|
||||||
requester={resolveRequester(approval, employees, agents)}
|
requester={resolveRequester(approval, employees, agents)}
|
||||||
decider={resolveDecider(approval, employees)}
|
decider={resolveDecider(approval, employees)}
|
||||||
last={index === Math.min(decided.length, 24) - 1}
|
last={index === Math.min(decided.length, 24) - 1}
|
||||||
@@ -266,11 +288,13 @@ function ApprovalRow({
|
|||||||
|
|
||||||
function DecidedRow({
|
function DecidedRow({
|
||||||
approval,
|
approval,
|
||||||
|
drift,
|
||||||
requester,
|
requester,
|
||||||
decider,
|
decider,
|
||||||
last,
|
last,
|
||||||
}: {
|
}: {
|
||||||
approval: ApprovalRecord;
|
approval: ApprovalRecord;
|
||||||
|
drift: ApprovalDrift | null;
|
||||||
requester: string;
|
requester: string;
|
||||||
decider: string;
|
decider: string;
|
||||||
last: boolean;
|
last: boolean;
|
||||||
@@ -290,6 +314,7 @@ function DecidedRow({
|
|||||||
>
|
>
|
||||||
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : superseded ? "var(--text-3)" : "var(--red)"} />
|
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : superseded ? "var(--text-3)" : "var(--red)"} />
|
||||||
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
||||||
|
{drift && <Pill tone="amber">{drift === "changed" ? t("Changed since approval") : t("Removed since approval")}</Pill>}
|
||||||
<Avatar name={requester} size="sm" bot />
|
<Avatar name={requester} size="sm" bot />
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)}{superseded ? "" : ` · ${decider.split(" ")[0]}`}
|
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)}{superseded ? "" : ` · ${decider.split(" ")[0]}`}
|
||||||
|
|||||||
@@ -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<ApprovalPin> & { kind?: string };
|
||||||
|
if (value.kind === "file" || value.kind === "document") return value as ApprovalPin;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ApprovalDrift } from "./approval-pin";
|
||||||
import type { DesktopResult } from "./result";
|
import type { DesktopResult } from "./result";
|
||||||
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
import type { AppSnapshot, SearchResultRecord } from "./domain";
|
||||||
import type { ProviderModelEntry } from "./provider-models";
|
import type { ProviderModelEntry } from "./provider-models";
|
||||||
@@ -203,6 +204,8 @@ export interface DesktopApi {
|
|||||||
disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise<DesktopResult<AppSnapshot>>;
|
disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
grantConnector(input: GrantConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
grantConnector(input: GrantConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
/** Approved deliverables whose content changed or vanished since approval, checked on disk now. */
|
||||||
|
approvalDrifts(input: { orgId: string }): Promise<DesktopResult<Record<string, ApprovalDrift>>>;
|
||||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ export interface ApprovalRecord {
|
|||||||
requestedById: string | null;
|
requestedById: string | null;
|
||||||
sourceEntityType: string | null;
|
sourceEntityType: string | null;
|
||||||
sourceEntityId: 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;
|
state: ApprovalState;
|
||||||
decisionNotes: string | null;
|
decisionNotes: string | null;
|
||||||
decidedByEmployeeId: string | null;
|
decidedByEmployeeId: string | null;
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const IPC_CHANNELS = {
|
|||||||
disconnectComposioToolkit: "agentcompany:disconnect-composio-toolkit",
|
disconnectComposioToolkit: "agentcompany:disconnect-composio-toolkit",
|
||||||
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
|
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
|
||||||
grantConnector: "agentcompany:grant-connector",
|
grantConnector: "agentcompany:grant-connector",
|
||||||
|
approvalDrifts: "agentcompany:approval-drifts",
|
||||||
revokeConnector: "agentcompany:revoke-connector",
|
revokeConnector: "agentcompany:revoke-connector",
|
||||||
search: "agentcompany:search",
|
search: "agentcompany:search",
|
||||||
rendererEvent: "agentcompany:renderer-event",
|
rendererEvent: "agentcompany:renderer-event",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
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 { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||||
import { ApprovalService } from "../../src/main/services/approval-service";
|
import { ApprovalService } from "../../src/main/services/approval-service";
|
||||||
import { runJournalLines } from "../../src/main/services/run-journal";
|
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 { executionApprovalProblem, requireAssignedAgent } from "../../src/main/services/task-run-preparation";
|
||||||
import { TASK_PRIORITY } from "../../src/shared/domain";
|
import { TASK_PRIORITY } from "../../src/shared/domain";
|
||||||
import type { TaskExecutionTaskRow } from "../../src/main/services/task-prompts";
|
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(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." }]);
|
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", () => {
|
test("a declined deliverable is never written, and held work keeps its task open", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user