feat: deliverable review gate, app lifecycle fixes, CI/CD pipeline
- Add deliverable review gate: pause workflow when agents produce important documents (PRDs, design docs, etc.) for human review before continuing - Add company-level reviewDeliverables setting with onboarding card selector - Block agent heartbeats when deliverable review is pending (agent + CEO) - Trigger review at document creation time in API server for reliability - Fix autoApproveHires vs reviewDeliverables conflict in approval-policy - Fix app quit: force-exit on all platforms (macOS Dock, Windows tray, Linux) - Add multi-platform CI/CD pipeline (macOS arm64/x64, Windows x64/arm64, Linux x64/arm64 with deb/AppImage/rpm/snap) with code signing support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9e2c19259a
commit
d52985f61e
24 files changed
+737
-48
No files matched your search
@@ -0,0 +1,227 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: 'Publish to GitHub Releases'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PNPM_VERSION: '10.24.0'
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 1: Lint & Typecheck (fast feedback)
|
||||
# ─────────────────────────────────────────────
|
||||
check:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm exec tsc --noEmit
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 2: Unit Tests
|
||||
# ─────────────────────────────────────────────
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 3: Build matrix (parallel per platform)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
build-macos:
|
||||
name: macOS (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: macos-14
|
||||
builder_args: --mac --arm64
|
||||
- arch: x64
|
||||
runner: macos-13
|
||||
builder_args: --mac --x64
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE_P12_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build electron-vite
|
||||
run: pnpm exec electron-vite build
|
||||
- name: Package & sign
|
||||
run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: |
|
||||
build/release/*.dmg
|
||||
build/release/*.zip
|
||||
build/release/*.blockmap
|
||||
build/release/latest-mac.yml
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
name: Windows (${{ matrix.arch }})
|
||||
runs-on: windows-latest
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
builder_args: --win --x64
|
||||
- arch: arm64
|
||||
builder_args: --win --arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build electron-vite
|
||||
run: pnpm exec electron-vite build
|
||||
- name: Package
|
||||
run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE_P12_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-${{ matrix.arch }}
|
||||
path: |
|
||||
build/release/*.exe
|
||||
build/release/*.blockmap
|
||||
build/release/latest.yml
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Linux (${{ matrix.arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
builder_args: --linux --x64
|
||||
- arch: arm64
|
||||
builder_args: --linux --arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install snapcraft (x64 only)
|
||||
if: matrix.arch == 'x64'
|
||||
run: sudo snap install snapcraft --classic
|
||||
- name: Build electron-vite
|
||||
run: pnpm exec electron-vite build
|
||||
- name: Package
|
||||
run: pnpm exec electron-builder ${{ matrix.builder_args }} --config electron-builder.yml --publish never
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-${{ matrix.arch }}
|
||||
path: |
|
||||
build/release/*.AppImage
|
||||
build/release/*.deb
|
||||
build/release/*.rpm
|
||||
build/release/*.snap
|
||||
build/release/*.blockmap
|
||||
build/release/latest-linux.yml
|
||||
if-no-files-found: warn
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Stage 4: Publish to GitHub Releases
|
||||
# ─────────────────────────────────────────────
|
||||
release:
|
||||
name: Publish Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-macos, build-windows, build-linux]
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: false
|
||||
|
||||
- name: Collect release files
|
||||
run: |
|
||||
mkdir -p release-files
|
||||
find artifacts -type f \( \
|
||||
-name "*.dmg" -o -name "*.zip" -o -name "*.exe" \
|
||||
-o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \
|
||||
-o -name "*.snap" -o -name "*.blockmap" \
|
||||
-o -name "latest*.yml" \
|
||||
\) -exec cp {} release-files/ \;
|
||||
ls -lh release-files/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: release-files/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -14,6 +14,15 @@ win-unpacked
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Signing certificates (NEVER commit these)
|
||||
*.p12
|
||||
*.cer
|
||||
*.pem
|
||||
*.key
|
||||
*.p8
|
||||
*.provisionprofile
|
||||
*.mobileprovision
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+60
-5
@@ -8,23 +8,78 @@ files:
|
||||
- package.json
|
||||
asar: true
|
||||
icon: assets/icon.png
|
||||
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
icon: assets/icon.icns
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
- target: dmg
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: assets/entitlements.mac.plist
|
||||
entitlementsInherit: assets/entitlements.mac.plist
|
||||
notarize: false
|
||||
|
||||
win:
|
||||
icon: assets/icon.ico
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: portable
|
||||
arch:
|
||||
- x64
|
||||
|
||||
linux:
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- AppImage
|
||||
- target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: deb
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
- target: snap
|
||||
arch:
|
||||
- x64
|
||||
category: Development
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
deb:
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libnotify4
|
||||
- libnss3
|
||||
- libxss1
|
||||
- libxtst6
|
||||
- xdg-utils
|
||||
- libatspi2.0-0
|
||||
- libuuid1
|
||||
- libsecret-1-0
|
||||
|
||||
snap:
|
||||
plugs:
|
||||
- default
|
||||
- removable-media
|
||||
|
||||
publish:
|
||||
provider: github
|
||||
owner: ${env.GH_REPO_OWNER}
|
||||
repo: ${env.GH_REPO_NAME}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-company",
|
||||
"version": "0.4.80",
|
||||
"version": "0.4.90",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||
|
||||
+76
-2
@@ -390,6 +390,47 @@ export class AgentApiServer {
|
||||
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Deliverable review gate: intercept agent trying to advance a flagged task to "done"
|
||||
let deliverableReviewIntercepted = false;
|
||||
if (update.status === "done" && task.requiresUserReview) {
|
||||
try {
|
||||
const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"];
|
||||
const placeholders = importantTypes.map(() => "?").join(", ");
|
||||
// Scope to documents authored by the task's agent, created during this task's lifetime
|
||||
const docs = this.db.queryAll<{ id: string; type: string; title: string }>(
|
||||
`select id, type, title from documents where company_id = ? and type in (${placeholders})
|
||||
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
|
||||
order by created_at desc limit 10`,
|
||||
task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id,
|
||||
);
|
||||
const reviewerAgent = this.db.getAgent(token.agentId);
|
||||
const docTitles = docs.map((d) => d.title).join(", ");
|
||||
this.db.requestApproval({
|
||||
companyId: task.companyId,
|
||||
relatedTaskId: task.id,
|
||||
requestedByAgentId: token.agentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280),
|
||||
impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400),
|
||||
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
|
||||
});
|
||||
this.db.setTaskRequiresUserReview(task.id, false);
|
||||
// Pause the producing agent's heartbeat until user reviews
|
||||
if (task.assigneeAgentId) {
|
||||
try {
|
||||
const prodAgent = this.db.getAgent(task.assigneeAgentId);
|
||||
if (prodAgent.heartbeatEnabled) {
|
||||
this.db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." });
|
||||
update.status = "in_review"; // Override: keep in review, don't advance to done
|
||||
deliverableReviewIntercepted = true;
|
||||
} catch { /* deliverable review interception is best-effort; fall through to normal flow */ }
|
||||
}
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
id: task.id,
|
||||
companyId: task.companyId,
|
||||
@@ -1393,10 +1434,12 @@ export class AgentApiServer {
|
||||
this.json(res, 400, { error: "Goal does not belong to the current company" });
|
||||
return;
|
||||
}
|
||||
const docType = String(data.type ?? "knowledge_article");
|
||||
const docTitle = String(data.title ?? "Untitled");
|
||||
const id = this.db.saveDocument({
|
||||
companyId: token.companyId,
|
||||
type: String(data.type ?? "knowledge_article"),
|
||||
title: String(data.title ?? "Untitled"),
|
||||
type: docType,
|
||||
title: docTitle,
|
||||
content: String(data.content ?? ""),
|
||||
authorAgentId: token.agentId,
|
||||
reviewerAgentId,
|
||||
@@ -1412,6 +1455,37 @@ export class AgentApiServer {
|
||||
if (reviewerAgentId) {
|
||||
this.onWakeAgent(reviewerAgentId, token.companyId, "assignment");
|
||||
}
|
||||
|
||||
// Deliverable review gate: check at document creation time (most reliable)
|
||||
const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]);
|
||||
if (IMPORTANT_DOC_TYPES.has(docType)) {
|
||||
try {
|
||||
const company = this.db.listSnapshot().companies.find((c) => c.id === token.companyId);
|
||||
if (company?.reviewDeliverables) {
|
||||
// Check if there's already a pending deliverable_review for this company
|
||||
if (!this.db.hasPendingDeliverableReviews(token.companyId)) {
|
||||
this.db.requestApproval({
|
||||
companyId: token.companyId,
|
||||
relatedTaskId: null,
|
||||
requestedByAgentId: token.agentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
|
||||
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
|
||||
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
|
||||
});
|
||||
// Pause the producing agent's heartbeat
|
||||
try {
|
||||
const agent = this.db.getAgent(token.agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
this.db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
this.onDomainChanged();
|
||||
}
|
||||
}
|
||||
} catch { /* deliverable review gate is best-effort */ }
|
||||
}
|
||||
|
||||
this.json(res, 201, { id });
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ interface AppLifecycleDependencies {
|
||||
export function createAppLifecycleService({
|
||||
getMainWindow,
|
||||
ensureWindow,
|
||||
shouldQuit: _shouldQuit,
|
||||
shouldQuit,
|
||||
onBeforeQuit,
|
||||
}: AppLifecycleDependencies) {
|
||||
function register(hasSingleInstanceLock: boolean) {
|
||||
@@ -44,9 +44,9 @@ export function createAppLifecycleService({
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// On macOS, keep the app running when all windows are closed (standard behavior).
|
||||
// On Windows and Linux, quit the app.
|
||||
if (process.platform !== "darwin") {
|
||||
// On macOS, keep the app running when all windows are closed (standard behavior)
|
||||
// UNLESS the user explicitly chose Quit (before-quit already fired).
|
||||
if (process.platform !== "darwin" || shouldQuit()) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,10 +11,19 @@ export function shouldAutoApproveApproval(
|
||||
return false;
|
||||
}
|
||||
|
||||
return db
|
||||
const company = db
|
||||
.listSnapshot()
|
||||
.companies
|
||||
.some((company) => company.id === companyId && company.autoApproveHires);
|
||||
.find((c) => c.id === companyId);
|
||||
|
||||
if (!company?.autoApproveHires) return false;
|
||||
|
||||
// When reviewDeliverables is ON, CEO strategy needs human review — don't auto-approve
|
||||
if (approvalType === "approve_ceo_strategy" && company.reviewDeliverables) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveApprovalDecision({
|
||||
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
import type { NotificationOptions } from "./notification-service";
|
||||
|
||||
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
|
||||
const IMPORTANT_DOCUMENT_TYPES = new Set([
|
||||
"prd", "technical_spec", "design_doc", "project_brief", "proposal",
|
||||
"budget_proposal", "architecture_decision", "contract", "sop",
|
||||
]);
|
||||
|
||||
const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
|
||||
"assign_task",
|
||||
"create_task",
|
||||
@@ -801,6 +807,57 @@ export function createAutomationService({
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
|
||||
function findImportantDocsForRun(runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
|
||||
try {
|
||||
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
|
||||
return db.queryAll<{ id: string; type: string; title: string }>(
|
||||
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and created_at >= (select started_at from runs where id = ?)`,
|
||||
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
|
||||
);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Creates a deliverable_review approval and fires OS notification. */
|
||||
function createDeliverableReviewApproval(
|
||||
companyId: string,
|
||||
taskId: string,
|
||||
agentId: string,
|
||||
docs: Array<{ id: string; type: string; title: string }>,
|
||||
reviewerNote?: string,
|
||||
): string {
|
||||
const docTitles = docs.map((d) => d.title).join(", ");
|
||||
const approvalId = db.requestApproval({
|
||||
companyId,
|
||||
relatedTaskId: taskId,
|
||||
requestedByAgentId: agentId,
|
||||
type: "deliverable_review",
|
||||
payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280),
|
||||
impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.",
|
||||
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
|
||||
});
|
||||
db.setTaskRequiresUserReview(taskId, false);
|
||||
// Pause the producing agent's heartbeat until the user reviews
|
||||
try {
|
||||
const agent = db.getAgent(agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
try {
|
||||
notify({
|
||||
title: "Deliverable Ready for Review",
|
||||
body: `${docTitles}`.slice(0, 200),
|
||||
urgency: "informational",
|
||||
navigation: { section: "approvals", entityId: approvalId },
|
||||
batchKey: "deliverable_review",
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
publishDomainChanged();
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
|
||||
let task: ReturnType<typeof db.getTask> | null = null;
|
||||
try {
|
||||
@@ -841,6 +898,27 @@ export function createAutomationService({
|
||||
db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review.");
|
||||
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
|
||||
effectiveStatus = "in_review";
|
||||
// Check if deliverable review gate should activate
|
||||
try {
|
||||
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
|
||||
if (company?.reviewDeliverables) {
|
||||
const importantDocs = findImportantDocsForRun(run.id, run.companyId);
|
||||
if (importantDocs.length > 0) {
|
||||
db.setTaskRequiresUserReview(task.id, true);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
// Pause heartbeat immediately — agent should not run while deliverable awaits review
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
|
||||
}
|
||||
// If agent has no manager (is CEO), create approval immediately
|
||||
const chain = db.getChainOfCommand(agent.id);
|
||||
if (chain.length === 0) {
|
||||
createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* deliverable review gate is best-effort */ }
|
||||
publishDomainChanged();
|
||||
}
|
||||
} else {
|
||||
@@ -848,6 +926,25 @@ export function createAutomationService({
|
||||
db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review.");
|
||||
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
|
||||
effectiveStatus = "in_review";
|
||||
// Check if deliverable review gate should activate for code/general tasks too
|
||||
try {
|
||||
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
|
||||
if (company?.reviewDeliverables) {
|
||||
const importantDocs = findImportantDocsForRun(run.id, run.companyId);
|
||||
if (importantDocs.length > 0) {
|
||||
db.setTaskRequiresUserReview(task.id, true);
|
||||
const agent = db.getAgent(run.agentId);
|
||||
if (agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
|
||||
}
|
||||
const chain = db.getChainOfCommand(agent.id);
|
||||
if (chain.length === 0) {
|
||||
createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* deliverable review gate is best-effort */ }
|
||||
publishDomainChanged();
|
||||
}
|
||||
}
|
||||
@@ -976,6 +1073,42 @@ export function createAutomationService({
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.type === "deliverable_review" && approval.relatedTaskId) {
|
||||
try {
|
||||
const task = db.getTask(approval.relatedTaskId);
|
||||
// Re-enable heartbeat for the producing agent now that the user has decided
|
||||
if (task.assigneeAgentId) {
|
||||
try {
|
||||
const agent = db.getAgent(task.assigneeAgentId);
|
||||
if (!agent.heartbeatEnabled) {
|
||||
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
|
||||
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
if (decision === "approved") {
|
||||
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "done");
|
||||
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
|
||||
publishDomainChanged();
|
||||
} else if (decision === "rejected") {
|
||||
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
|
||||
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
publishDomainChanged();
|
||||
} else if (decision === "revision_requested") {
|
||||
if (task.assigneeAgentId) {
|
||||
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
|
||||
}
|
||||
publishDomainChanged();
|
||||
}
|
||||
} catch {
|
||||
// Deliverable review follow-up is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
|
||||
if (browserAction) {
|
||||
if (decision === "approved") {
|
||||
|
||||
+54
-10
@@ -528,8 +528,8 @@ export class AppDatabase {
|
||||
secretBackend: this.vault.getBackendLabel(),
|
||||
},
|
||||
companies: this.readAll<CompanyRecord>(
|
||||
"select id, name, description, status, auto_approve_hires as autoApproveHires, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc",
|
||||
).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires) })) as CompanyRecord[],
|
||||
"select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc",
|
||||
).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[],
|
||||
connectors,
|
||||
workspaces: this.readAll<WorkspaceRecord>(
|
||||
`select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`,
|
||||
@@ -548,9 +548,9 @@ export class AppDatabase {
|
||||
...companyArgs,
|
||||
),
|
||||
tasks: this.readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
),
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview) })) as TaskRecord[],
|
||||
approvals: this.readAll<ApprovalRecord>(
|
||||
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`,
|
||||
...companyArgs,
|
||||
@@ -675,19 +675,20 @@ export class AppDatabase {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean }) {
|
||||
saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean }) {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
const autoApprove = input.autoApproveHires ? 1 : 0;
|
||||
const reviewDeliverables = input.reviewDeliverables ? 1 : 0;
|
||||
const existing = this.db.prepare("select id from companies where id = ?").get(id) as { id: string } | undefined;
|
||||
if (existing) {
|
||||
this.db
|
||||
.prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, updated_at = ? where id = ?")
|
||||
.run(input.name, input.description, input.status, autoApprove, ts, id);
|
||||
.prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, updated_at = ? where id = ?")
|
||||
.run(input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, id);
|
||||
} else {
|
||||
this.db
|
||||
.prepare("insert into companies (id, name, description, status, auto_approve_hires, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, input.name, input.description, input.status, autoApprove, ts, ts);
|
||||
.prepare("insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, ts);
|
||||
if (!this.getCurrentCompanyId()) {
|
||||
this.setCurrentCompany(id);
|
||||
}
|
||||
@@ -1098,10 +1099,11 @@ export class AppDatabase {
|
||||
getTask(id: string): TaskRecord {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
)
|
||||
.get(id) as TaskRecord | undefined;
|
||||
if (!row) throw new Error(`Task not found: ${id}`);
|
||||
row.requiresUserReview = Boolean(row.requiresUserReview);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -2180,7 +2182,9 @@ export class AppDatabase {
|
||||
addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120");
|
||||
addColumnIfMissing("alter table agents add column last_heartbeat_at text");
|
||||
addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0");
|
||||
addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column task_type text not null default 'general'");
|
||||
addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column retry_count integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column last_failure_at text");
|
||||
this.db.exec(`
|
||||
@@ -2275,6 +2279,7 @@ export class AppDatabase {
|
||||
or (type = 'dangerous_command' and ${taskCondition})
|
||||
or (type = 'secret_access' and ${taskCondition})
|
||||
or (type = 'approve_ceo_strategy' and ${taskCondition})
|
||||
or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition})))
|
||||
)
|
||||
order by created_at asc limit 1`,
|
||||
companyId,
|
||||
@@ -2282,6 +2287,8 @@ export class AppDatabase {
|
||||
...taskParams,
|
||||
...taskParams,
|
||||
...taskParams,
|
||||
agentId,
|
||||
...taskParams,
|
||||
);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
}
|
||||
@@ -2802,6 +2809,43 @@ export class AppDatabase {
|
||||
return { previousStatus };
|
||||
}
|
||||
|
||||
setTaskRequiresUserReview(taskId: string, value: boolean) {
|
||||
const ts = nowIso();
|
||||
this.db.prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?").run(value ? 1 : 0, ts, taskId);
|
||||
}
|
||||
|
||||
/** Returns true if the company has any pending deliverable_review approvals or tasks awaiting review. */
|
||||
hasPendingDeliverableReviews(companyId: string): boolean {
|
||||
const approval = this.db.prepare(
|
||||
"select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1",
|
||||
).get(companyId);
|
||||
if (approval) return true;
|
||||
const flaggedTask = this.db.prepare(
|
||||
"select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1",
|
||||
).get(companyId);
|
||||
return !!flaggedTask;
|
||||
}
|
||||
|
||||
/** Returns true if this specific agent has tasks with pending deliverable_review approvals. */
|
||||
hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean {
|
||||
const row = this.db.prepare(
|
||||
`select 1 from approvals a
|
||||
join tasks t on a.related_task_id = t.id
|
||||
where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending'
|
||||
and t.assignee_agent_id = ?
|
||||
limit 1`,
|
||||
).get(companyId, agentId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/** Returns true if agent has any tasks flagged requires_user_review (before approval is created). */
|
||||
hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean {
|
||||
const row = this.db.prepare(
|
||||
"select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1",
|
||||
).get(agentId, companyId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
private incrementActiveSprintVelocity(companyId: string, delta: number) {
|
||||
const ts = nowIso();
|
||||
const today = ts.slice(0, 10);
|
||||
|
||||
@@ -346,6 +346,10 @@ export function registerCoreWorkGraphHandlers({
|
||||
if (previousStatus && previousStatus !== parsed.status) {
|
||||
handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status);
|
||||
}
|
||||
// User (board) manually advancing a flagged task to done: clear the review flag
|
||||
if (parsed.status === "done") {
|
||||
try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ }
|
||||
}
|
||||
// Wake new assignee when task is reassigned (matches api-server.ts behavior)
|
||||
if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) {
|
||||
wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment");
|
||||
|
||||
@@ -17,6 +17,7 @@ interface BootstrapInput {
|
||||
companyDescription: string;
|
||||
workspacePath: string;
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables?: boolean;
|
||||
goalTitle: string;
|
||||
goalDescription: string;
|
||||
connectorId?: ConnectorId;
|
||||
@@ -188,6 +189,7 @@ export function createOnboardingService({
|
||||
description: input.companyDescription.trim(),
|
||||
status: "active",
|
||||
autoApproveHires: input.autoApproveHires,
|
||||
reviewDeliverables: input.reviewDeliverables,
|
||||
});
|
||||
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
|
||||
|
||||
|
||||
@@ -335,6 +335,20 @@ export function createOrchestrationService({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block heartbeat when agent has tasks awaiting user deliverable review
|
||||
// Check both: pending approval (gate created) and requires_user_review flag (gap before approval)
|
||||
if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) {
|
||||
logger.info(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block CEO heartbeat when company has any pending deliverable reviews
|
||||
// CEO should not create new work while the user hasn't reviewed existing deliverables
|
||||
if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
|
||||
logger.info(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const isTimer = trigger === "timer";
|
||||
const candidateTasks = isTimer
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { app } from "electron";
|
||||
import type { AgentApiServer } from "./api-server";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { BrowserManager } from "./browser-manager";
|
||||
@@ -225,6 +226,9 @@ export function createServiceGraph({
|
||||
updaterService = null;
|
||||
appLifecycleService = null;
|
||||
db.close();
|
||||
// Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux)
|
||||
// Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely
|
||||
setTimeout(() => { app.exit(0); }, 3000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,8 +426,15 @@ export function createWorkerService({
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
worker?.kill();
|
||||
worker = null;
|
||||
if (worker) {
|
||||
worker.kill();
|
||||
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
|
||||
const ref = worker;
|
||||
setTimeout(() => {
|
||||
try { ref.kill(); } catch { /* already dead */ }
|
||||
}, 2000);
|
||||
worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1087,6 +1087,7 @@ export function App() {
|
||||
approval={selectedApproval}
|
||||
agents={companyAgents}
|
||||
tasks={tasks}
|
||||
documents={companyDocuments}
|
||||
companyId={currentCompany!.id}
|
||||
onDecide={async (state, note) => {
|
||||
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Buildings, FolderOpen } from "@phosphor-icons/react";
|
||||
import { Buildings, FolderOpen, Lightning, UserCircleCheck } from "@phosphor-icons/react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
@@ -16,6 +16,8 @@ interface CompanyStepProps {
|
||||
setWorkspacePath: (v: string) => void;
|
||||
autoApproveHires: boolean;
|
||||
setAutoApproveHires: (v: boolean) => void;
|
||||
reviewDeliverables: boolean;
|
||||
setReviewDeliverables: (v: boolean) => void;
|
||||
onPickDirectory: () => void;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
@@ -38,6 +40,8 @@ export function CompanyStep({
|
||||
setWorkspacePath,
|
||||
autoApproveHires,
|
||||
setAutoApproveHires,
|
||||
reviewDeliverables,
|
||||
setReviewDeliverables,
|
||||
onPickDirectory,
|
||||
onContinue,
|
||||
onBack,
|
||||
@@ -111,21 +115,28 @@ export function CompanyStep({
|
||||
{t("onboarding.workspaceHelp")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoApproveHires}
|
||||
onChange={(event) => setAutoApproveHires(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[color:var(--text)]">{t("onboarding.autoApproveLabel")}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
{t("onboarding.autoApproveHelp")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-[color:var(--muted-strong)]">{t("onboarding.automationMode")}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setReviewDeliverables(false); setAutoApproveHires(true) }}
|
||||
className={`cursor-pointer rounded-[8px] border-2 px-4 py-3.5 text-left transition ${!reviewDeliverables ? "border-[color:var(--accent)] bg-[color:var(--accent-soft)]" : "border-[color:var(--line)] bg-[color:var(--panel-soft)] hover:border-[color:var(--muted)]"}`}
|
||||
>
|
||||
<Lightning size={20} weight={!reviewDeliverables ? "fill" : "regular"} className={!reviewDeliverables ? "text-[color:var(--accent)]" : "text-[color:var(--muted)]"} />
|
||||
<div className="mt-2 text-[13px] font-semibold text-[color:var(--text)]">{t("onboarding.modeFullAuto")}</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-[color:var(--muted)]">{t("onboarding.modeFullAutoDesc")}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setReviewDeliverables(true); setAutoApproveHires(false) }}
|
||||
className={`cursor-pointer rounded-[8px] border-2 px-4 py-3.5 text-left transition ${reviewDeliverables ? "border-[color:var(--accent)] bg-[color:var(--accent-soft)]" : "border-[color:var(--line)] bg-[color:var(--panel-soft)] hover:border-[color:var(--muted)]"}`}
|
||||
>
|
||||
<UserCircleCheck size={20} weight={reviewDeliverables ? "fill" : "regular"} className={reviewDeliverables ? "text-[color:var(--accent)]" : "text-[color:var(--muted)]"} />
|
||||
<div className="mt-2 text-[13px] font-semibold text-[color:var(--text)]">{t("onboarding.modeHumanReview")}</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-[color:var(--muted)]">{t("onboarding.modeHumanReviewDesc")}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export function CompanyOnboarding({
|
||||
const [companyDescription, setCompanyDescription] = useState("");
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
const [autoApproveHires, setAutoApproveHires] = useState(true);
|
||||
const [reviewDeliverables, setReviewDeliverables] = useState(false);
|
||||
const [goalTitle, setGoalTitle] = useState("");
|
||||
const [goalDescription, setGoalDescription] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -123,6 +124,7 @@ export function CompanyOnboarding({
|
||||
companyDescription: companyDescription.trim(),
|
||||
workspacePath: workspacePath.trim(),
|
||||
autoApproveHires,
|
||||
reviewDeliverables,
|
||||
goalTitle: goalTitle.trim(),
|
||||
goalDescription: goalDescription.trim(),
|
||||
connectorId: selectedConnector?.id,
|
||||
@@ -131,6 +133,7 @@ export function CompanyOnboarding({
|
||||
setStep("launch");
|
||||
}, [
|
||||
autoApproveHires,
|
||||
reviewDeliverables,
|
||||
companyDescription,
|
||||
companyName,
|
||||
goalDescription,
|
||||
@@ -231,6 +234,8 @@ export function CompanyOnboarding({
|
||||
setWorkspacePath={setWorkspacePath}
|
||||
autoApproveHires={autoApproveHires}
|
||||
setAutoApproveHires={setAutoApproveHires}
|
||||
reviewDeliverables={reviewDeliverables}
|
||||
setReviewDeliverables={setReviewDeliverables}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onContinue={() => void continueToMission()}
|
||||
onBack={() => setStep("language")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { Briefcase, CheckCircle, Clock, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatMoney, formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
@@ -14,11 +15,12 @@ function getTypeBadgeClass(type: string): string {
|
||||
if (type === "approve_ceo_strategy") return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
|
||||
if (type === "secret_access") return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
|
||||
if (type === "social_post") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||
if (type === "deliverable_review") return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
|
||||
return "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
|
||||
}
|
||||
|
||||
export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide, onDeleted }: {
|
||||
approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; companyId: string
|
||||
export function ApprovalInspector({ approval, agents, tasks, documents, companyId, onDecide, onDeleted }: {
|
||||
approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; documents?: DocumentRecord[]; companyId: string
|
||||
onDecide: (state: "approved" | "rejected" | "revision_requested", note?: string) => Promise<void>; onDeleted: () => Promise<void>
|
||||
}) {
|
||||
const t = useT()
|
||||
@@ -112,6 +114,7 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide
|
||||
{mgr ? <div><span className="text-[color:var(--muted)]">Reports to:</span> <span className="text-[color:var(--text)]">{mgr.name}</span></div> : null}
|
||||
</div></div>) })() : null}
|
||||
{approval.type === "approve_ceo_strategy" ? <div className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--text)]">{approval.payloadSummary}</div></div> : null}
|
||||
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<SocialDraftPreview
|
||||
payloadJson={approval.payloadJson}
|
||||
@@ -187,3 +190,46 @@ export function ApprovalInspector({ approval, agents, tasks, companyId, onDecide
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliverableReviewPanel({ approval, documents, agents }: { approval: ApprovalRecord; documents?: DocumentRecord[]; agents: AgentRecord[] }) {
|
||||
const payload = parseApprovalPayloadJson(approval)
|
||||
const docRefs = Array.isArray(payload?.documents) ? payload.documents as Array<{ id: string; type: string; title: string }> : []
|
||||
const docIds = Array.isArray(payload?.documentIds) ? payload.documentIds as string[] : docRefs.map((d) => d.id)
|
||||
const fullDocs = documents ? docIds.map((id) => documents.find((d) => d.id === id)).filter(Boolean) as DocumentRecord[] : []
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(fullDocs.length === 1 ? fullDocs[0]!.id : null)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--accent)]">Deliverable Review</div>
|
||||
{fullDocs.length > 0 ? fullDocs.map((doc) => {
|
||||
const isExpanded = expandedDocId === doc.id
|
||||
const author = doc.authorAgentId ? agents.find((a) => a.id === doc.authorAgentId) : null
|
||||
return (
|
||||
<div key={doc.id} className="rounded-[8px] border border-[color:var(--line)] overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedDocId(isExpanded ? null : doc.id)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 bg-[color:var(--panel-soft)] px-4 py-3 text-left transition hover:bg-[color:var(--panel)]"
|
||||
>
|
||||
<span className="shrink-0 rounded-full bg-[color:var(--accent-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[color:var(--accent)]">{doc.type.replaceAll("_", " ")}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
|
||||
{isExpanded ? <CaretUp size={14} className="shrink-0 text-[color:var(--muted)]" /> : <CaretDown size={14} className="shrink-0 text-[color:var(--muted)]" />}
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="border-t border-[color:var(--line)] px-4 py-3">
|
||||
{author ? <div className="mb-2 text-[11px] text-[color:var(--muted)]">Author: <span className="font-medium text-[color:var(--text)]">{author.name}</span> ({author.role})</div> : null}
|
||||
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--text)]">{doc.content || "No content."}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}) : docRefs.length > 0 ? docRefs.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center gap-2 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3 text-[12px]">
|
||||
<span className="rounded-full bg-[color:var(--accent-soft)] px-2 py-0.5 text-[10px] font-semibold uppercase text-[color:var(--accent)]">{doc.type.replaceAll("_", " ")}</span>
|
||||
<span className="font-medium text-[color:var(--text)]">{doc.title}</span>
|
||||
</div>
|
||||
)) : <div className="text-[12px] text-[color:var(--muted)]">{approval.payloadSummary}</div>}
|
||||
{approval.impactSummary ? <div className="text-[11px] text-[color:var(--muted)]">{approval.impactSummary}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,10 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
|
||||
</div>
|
||||
{(() => { const company = snapshot.companies.find((c) => c.id === companyId); if (!company) return null; return (
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.companyAutonomy")}</div>
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--accent)]" /><div><div className="text-[13px] font-medium text-[color:var(--text)]">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-[color:var(--muted)]">{t("exec.autoApproveDesc")}</div></div></label></div></div>) })()}
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--accent)]" /><div><div className="text-[13px] font-medium text-[color:var(--text)]">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-[color:var(--muted)]">{t("exec.autoApproveDesc")}</div></div></label></div>
|
||||
<div className="rounded-[8px] border border-[color:var(--line)] px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--accent)]" /><div><div className="text-[13px] font-medium text-[color:var(--text)]">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-[color:var(--muted)]">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
|
||||
</div></div>) })()}
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secretBackend")}</div><div className="border-y border-[color:var(--line)] py-3 text-[13px] text-[color:var(--muted-strong)]">{snapshot.backend.secretBackend}</div></div>
|
||||
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-[color:var(--line)] overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-[color:var(--line)]"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-[color:var(--line)] last:border-b-0"><td className="px-4 py-3 font-medium text-[color:var(--text)]">{s.name}</td><td className="px-4 py-3 text-[color:var(--muted)]">{s.description || "\u2014"}</td><td className="px-4 py-3 mono text-[color:var(--muted-strong)]">{s.backend}</td><td className="px-4 py-3 mono text-[color:var(--muted)]">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
|
||||
|
||||
@@ -73,6 +73,13 @@ export const en: Record<string, string> = {
|
||||
"onboarding.autoApprove": "Auto-approve hire and strategy approvals",
|
||||
"onboarding.autoApproveLabel": "Auto-approve hire and strategy approvals",
|
||||
"onboarding.autoApproveHelp": "Skips board review for hiring and strategy changes.",
|
||||
"onboarding.reviewDeliverablesLabel": "Review important deliverables",
|
||||
"onboarding.reviewDeliverablesHelp": "Pause workflow for your review on PRDs, design docs, and key documents.",
|
||||
"onboarding.automationMode": "Automation mode",
|
||||
"onboarding.modeFullAuto": "Full automation",
|
||||
"onboarding.modeFullAutoDesc": "Agents run end-to-end without pausing. Best for hands-off operation.",
|
||||
"onboarding.modeHumanReview": "Human in the loop",
|
||||
"onboarding.modeHumanReviewDesc": "Pause for your review on PRDs, design docs, and other key deliverables.",
|
||||
"onboarding.continue": "Continue",
|
||||
"onboarding.back": "Back",
|
||||
"onboarding.goal": "Goal",
|
||||
@@ -426,6 +433,8 @@ export const en: Record<string, string> = {
|
||||
"exec.companyAutonomy": "Company autonomy",
|
||||
"exec.autoApproveLabel": "Auto-approve hire and strategy approvals",
|
||||
"exec.autoApproveDesc": "Auto-approve hiring and CEO strategy decisions without board review.",
|
||||
"exec.reviewDeliverablesLabel": "Auto-approve important deliverables",
|
||||
"exec.reviewDeliverablesDesc": "Skip board review when agents produce PRDs, design docs, and other key documents.",
|
||||
"exec.actions": "Actions",
|
||||
"exec.saveSettings": "Save settings",
|
||||
"exec.checkForUpdates": "Check for updates",
|
||||
|
||||
@@ -73,6 +73,13 @@ export const zh: Record<string, string> = {
|
||||
"onboarding.autoApprove": "自动批准招聘和战略审批",
|
||||
"onboarding.autoApproveLabel": "自动批准招聘和战略审批",
|
||||
"onboarding.autoApproveHelp": "跳过招聘和战略变更的董事会审批。",
|
||||
"onboarding.reviewDeliverablesLabel": "重要产出物人工审核",
|
||||
"onboarding.reviewDeliverablesHelp": "当 Agent 产出 PRD、设计文档等重要产出物时暂停流程,等待您审核。",
|
||||
"onboarding.automationMode": "自动化模式",
|
||||
"onboarding.modeFullAuto": "全自动",
|
||||
"onboarding.modeFullAutoDesc": "Agent 全程自动运行,无需人工干预。适合放手运营。",
|
||||
"onboarding.modeHumanReview": "人工审核",
|
||||
"onboarding.modeHumanReviewDesc": "当产出 PRD、设计文档等重要产出物时暂停,等待您审核后继续。",
|
||||
"onboarding.continue": "继续",
|
||||
"onboarding.back": "返回",
|
||||
"onboarding.goal": "目标",
|
||||
@@ -426,6 +433,8 @@ export const zh: Record<string, string> = {
|
||||
"exec.companyAutonomy": "公司自治",
|
||||
"exec.autoApproveLabel": "自动批准招聘和战略审批",
|
||||
"exec.autoApproveDesc": "自动批准招聘和 CEO 战略决策,无需董事会审批。",
|
||||
"exec.reviewDeliverablesLabel": "自动通过重要产出物",
|
||||
"exec.reviewDeliverablesDesc": "自动通过 Agent 产出的 PRD、设计文档等重要产出物,无需人工审核。",
|
||||
"exec.actions": "操作",
|
||||
"exec.saveSettings": "保存设置",
|
||||
"exec.checkForUpdates": "检查更新",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const companyInputSchema = z.object({
|
||||
description: z.string().max(500).default(""),
|
||||
status: z.enum(["active", "paused", "archived"]).default("active"),
|
||||
autoApproveHires: z.boolean().default(false),
|
||||
reviewDeliverables: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const connectorInputSchema = z.object({
|
||||
@@ -180,7 +181,7 @@ export const approvalInputSchema = z.object({
|
||||
relatedTaskId: optionalId,
|
||||
requestedByAgentId: optionalId,
|
||||
relatedAgentId: optionalId,
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post"]),
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post", "deliverable_review"]),
|
||||
payloadSummary: z.string().min(2).max(280),
|
||||
impactSummary: z.string().min(2).max(400),
|
||||
payloadJson: z.string().nullable().optional(),
|
||||
@@ -287,6 +288,7 @@ export const bootstrapOnboardingSchema = z.object({
|
||||
companyDescription: z.string().max(500).default(""),
|
||||
workspacePath: z.string().default(""),
|
||||
autoApproveHires: z.boolean().default(true),
|
||||
reviewDeliverables: z.boolean().default(false),
|
||||
goalTitle: z.string().default(""),
|
||||
goalDescription: z.string().default(""),
|
||||
connectorId: z.enum(["codex_local", "claude_local", "gemini_local"]).optional(),
|
||||
|
||||
+4
-1
@@ -65,7 +65,8 @@ export type ApprovalType =
|
||||
| "document_review"
|
||||
| "production_deploy"
|
||||
| "incident_escalation"
|
||||
| "social_post";
|
||||
| "social_post"
|
||||
| "deliverable_review";
|
||||
export type RunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
@@ -89,6 +90,7 @@ export interface CompanyRecord {
|
||||
description: string;
|
||||
status: CompanyStatus;
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -202,6 +204,7 @@ export interface TaskRecord {
|
||||
sessionJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number;
|
||||
requiresUserReview: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user