diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index b5e6700f..9d803dca 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -88,6 +88,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input))); ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input))); ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input))); + ipcMain.handle(IPC_CHANNELS.createScheduledPost, (_event, input) => mutate(() => social.createScheduledPost(input))); + ipcMain.handle(IPC_CHANNELS.setScheduledPostState, (_event, input) => mutate(() => social.setScheduledPostState(input))); + ipcMain.handle(IPC_CHANNELS.setFilePinned, (_event, input) => mutate(() => files.setPinned(input.orgId, input.fileId, input.pinned))); ipcMain.handle(IPC_CHANNELS.launchSocialLogin, (_event, input) => { try { diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 76c8bb96..86c00190 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; -const SCHEMA_VERSION = 3; +const SCHEMA_VERSION = 4; export function installSchema(db: DatabaseSync): void { db.exec(` @@ -455,5 +455,34 @@ export function installSchema(db: DatabaseSync): void { insert into schema_migrations(version, name, applied_at) values (3, 'onboarding_execution_schema', datetime('now')); `); + currentVersion = 3; + } + + if (currentVersion < 4) { + db.exec(` + alter table file_objects add column pinned integer not null default 0; + alter table file_objects add column space_id text; + alter table file_objects add column updated_at text; + + create table if not exists scheduled_posts ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + social_account_id text not null references social_accounts(id) on delete cascade, + body_md text not null, + scheduled_for text not null, + state text not null, + approval_id text references approvals(id) on delete set null, + created_by_employee_id text references employees(id) on delete set null, + created_at text not null, + updated_at text not null + ); + + create index if not exists idx_files_org_pinned on file_objects(org_id, pinned); + create index if not exists idx_scheduled_posts_org_state on scheduled_posts(org_id, state); + create index if not exists idx_scheduled_posts_account_date on scheduled_posts(social_account_id, scheduled_for); + + insert into schema_migrations(version, name, applied_at) + values (4, 'files_pinned_and_scheduled_posts', datetime('now')); + `); } } diff --git a/AgentCompany-New/src/main/services/file-service.ts b/AgentCompany-New/src/main/services/file-service.ts index 988ace35..19b8f7c9 100644 --- a/AgentCompany-New/src/main/services/file-service.ts +++ b/AgentCompany-New/src/main/services/file-service.ts @@ -49,8 +49,11 @@ export class FileService { const target = join(orgDir, `${id}-${basename(sourcePath)}`); copyFileSync(sourcePath, target); this.db.prepare(` - insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, + owner_type, owner_id, space_id, pinned, created_at, updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, orgId, @@ -61,6 +64,9 @@ export class FileService { target, "system", null, + null, + 0, + now, now, ); this.audit.record({ @@ -74,4 +80,20 @@ export class FileService { }); return id; } + + setPinned(orgId: string, fileId: string, pinned: boolean): void { + const result = this.db.prepare( + "update file_objects set pinned = ?, updated_at = ? where id = ? and org_id = ?", + ).run(pinned ? 1 : 0, new Date().toISOString(), fileId, orgId); + if (result.changes === 0) throw new Error("File was not found."); + this.audit.record({ + orgId, + entityType: "file_object", + entityId: fileId, + actorType: "employee", + actorId: null, + action: pinned ? "pinned" : "unpinned", + payload: { fileId }, + }); + } } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 39567a6e..d76c80b5 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -45,6 +45,8 @@ export class SnapshotService { .map((provider) => ({ ...provider, enabled: toBool(provider.enabled) })); const skillBindings = list(this.db, "skill_bindings", "priority asc") .map((binding) => ({ ...binding, enabled: toBool(binding.enabled) })); + const files = list(this.db, "file_objects", "created_at desc") + .map((file) => ({ ...file, pinned: toBool(file.pinned) })); return { activeOrgId, @@ -62,7 +64,8 @@ export class SnapshotService { chatThreads: list(this.db, "chat_threads", "updated_at desc"), chatAttachments: list(this.db, "chat_attachments", "message_id asc"), chatPresence: list(this.db, "chat_presence", "last_seen_at desc"), - files: list(this.db, "file_objects", "created_at desc"), + files, + scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"), providers, providerCatalog: PROVIDER_CATALOG, providerRequests: list(this.db, "provider_requests", "created_at desc"), diff --git a/AgentCompany-New/src/main/services/social-service.ts b/AgentCompany-New/src/main/services/social-service.ts index f53dc858..f1c09a27 100644 --- a/AgentCompany-New/src/main/services/social-service.ts +++ b/AgentCompany-New/src/main/services/social-service.ts @@ -1,6 +1,10 @@ import type { DatabaseSync } from "node:sqlite"; import { BrowserWindow, session } from "electron"; -import type { CreateSocialAccountInput } from "../../shared/commands"; +import type { + CreateScheduledPostInput, + CreateSocialAccountInput, + SetScheduledPostStateInput, +} from "../../shared/commands"; import { SOCIAL_PLATFORM, type SocialPlatform } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; @@ -26,6 +30,63 @@ export class SocialService { this.audit = new AuditService(db); } + createScheduledPost(input: CreateScheduledPostInput): string { + const account = this.db.prepare("select id from social_accounts where id = ? and org_id = ?") + .get(input.socialAccountId, input.orgId) as { id: string } | undefined; + if (!account) throw new Error("Social account was not found."); + const body = input.bodyMd.trim(); + if (!body) throw new Error("Post body is required."); + const scheduledFor = input.scheduledFor.trim(); + if (!scheduledFor) throw new Error("Scheduled date is required."); + const id = createId("spost"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into scheduled_posts( + id, org_id, social_account_id, body_md, scheduled_for, + state, approval_id, created_by_employee_id, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.orgId, + input.socialAccountId, + body, + scheduledFor, + input.state, + null, + input.createdByEmployeeId, + now, + now, + ); + this.audit.record({ + orgId: input.orgId, + entityType: "scheduled_post", + entityId: id, + actorType: input.createdByEmployeeId ? "employee" : "system", + actorId: input.createdByEmployeeId, + action: "scheduled", + payload: { socialAccountId: input.socialAccountId, scheduledFor, state: input.state }, + }); + return id; + } + + setScheduledPostState(input: SetScheduledPostStateInput): void { + const now = new Date().toISOString(); + const result = this.db.prepare(` + update scheduled_posts set state = ?, updated_at = ? + where id = ? and org_id = ? + `).run(input.state, now, input.scheduledPostId, input.orgId); + if (result.changes === 0) throw new Error("Scheduled post was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "scheduled_post", + entityId: input.scheduledPostId, + actorType: "employee", + actorId: null, + action: `state_${input.state}`, + payload: { state: input.state }, + }); + } + createAccount(input: CreateSocialAccountInput): string { const id = createId("soc"); const now = new Date().toISOString(); diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index a1b216ce..ed3b9915 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -24,7 +24,10 @@ const api: DesktopApi = { resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input), createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input), launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input), + createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input), + setScheduledPostState: (input) => invoke(IPC_CHANNELS.setScheduledPostState, input), importFile: (input) => invoke(IPC_CHANNELS.importFile, input), + setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input), createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); diff --git a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx index 7829bb3e..99744222 100644 --- a/AgentCompany-New/src/renderer/src/app/Onboarding.tsx +++ b/AgentCompany-New/src/renderer/src/app/Onboarding.tsx @@ -1,10 +1,13 @@ import { useMemo, useState, type FormEvent } from "react"; -import { PROVIDER_AUTH_TYPE } from "../../../shared/domain"; +import { + PROVIDER_AUTH_TYPE, + type AppSnapshot, + type ProviderCatalogEntry, +} from "../../../shared/domain"; import { Button, Field, Icon, Pill } from "../shared/ui"; import type { SnapshotActions } from "./useSnapshot"; -import type { AppSnapshot, ProviderCatalogEntry } from "../../../shared/domain"; -type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "launch"; +type Step = "welcome" | "provider" | "company" | "mode" | "mission"; interface Draft { companyName: string; @@ -19,54 +22,62 @@ interface Draft { goalDescription: string; } -const steps: Array<{ id: Step; label: string }> = [ +const STEPS: Array<{ id: Step; label: string }> = [ { id: "welcome", label: "Start" }, { id: "provider", label: "Provider" }, { id: "company", label: "Company" }, { id: "mode", label: "Mode" }, { id: "mission", label: "Goal" }, - { id: "launch", label: "Launch" }, ]; -const initialDraft: Draft = { - companyName: "", - companyDescription: "", - employeeName: "", - workspacePath: "", - executionMode: "ai_ceo", - providerKey: "", - providerSecret: "", - providerModel: "", - goalTitle: "", - goalDescription: "", -}; - export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actions: SnapshotActions }) { const apiProviders = useMemo( () => snapshot.providerCatalog.filter((entry) => entry.authType === PROVIDER_AUTH_TYPE.API_KEY), [snapshot.providerCatalog], ); + const firstProvider = apiProviders[0]; + if (!firstProvider) throw new Error("Provider catalog must include at least one API-key provider."); + if (!firstProvider.defaultModel) throw new Error("Default API provider needs a default model."); + const [step, setStep] = useState("welcome"); - const [draft, setDraft] = useState(() => { - const firstProvider = apiProviders[0]; - if (!firstProvider?.defaultModel) throw new Error("Provider catalog requires a default API model."); - return { ...initialDraft, providerKey: firstProvider.key, providerModel: firstProvider.defaultModel }; + const [draft, setDraft] = useState({ + companyName: "", + companyDescription: "", + employeeName: "", + workspacePath: "", + executionMode: "ai_ceo", + providerKey: firstProvider.key, + providerSecret: "", + providerModel: firstProvider.defaultModel, + goalTitle: "", + goalDescription: "", }); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - const selectedProvider = requireProvider(apiProviders, draft.providerKey); + const selectedProvider = requireProvider(apiProviders, draft.providerKey); const update = (patch: Partial) => setDraft((current) => ({ ...current, ...patch })); - const go = (next: Step) => { + + const guard = (validator: () => string | null, next: Step) => () => { + const message = validator(); + if (message) { + setError(message); + return; + } setError(null); setStep(next); }; - const launch = async (event: FormEvent) => { + const back = (target: Step) => () => { + setError(null); + setStep(target); + }; + + const submit = async (event: FormEvent) => { event.preventDefault(); - const validation = validateDraft(draft); - if (validation) { - setError(validation); + const message = validateAll(draft); + if (message) { + setError(message); return; } setSubmitting(true); @@ -97,72 +108,96 @@ export function Onboarding({ snapshot, actions }: { snapshot: AppSnapshot; actio
-
+
A
-
AgentCompany
+
AgentCompany
-

Start your AI company.

-

- Create the company, bind the CEO runtime, and queue the first operating loop. +

Start your AI company.

+

+ Stand up the workspace, bind a runtime, and queue the first operating loop. Real data + from your provider, hairline UI, zero placeholder noise.

- {steps.map((item) => ( - {item.label} + {STEPS.map((entry) => ( + {entry.label} ))}
-
-
Selected runtime
-
- - {selectedProvider.label} +
+
Runtime selected
+
+ + {selectedProvider.label} {draft.providerModel}
+
+ We never embed the key in the build. Credentials encrypt locally via OS-protected storage. +
-
- {step === "welcome" && go("provider")} />} + + {step === "welcome" && ( + setStep("provider")} /> + )} {step === "provider" && ( go("welcome")} - onNext={() => validateProvider(draft) ? setError(validateProvider(draft)) : go("company")} + onBack={back("welcome")} + onNext={guard(() => validateProvider(draft), "company")} /> )} {step === "company" && ( go("provider")} - onNext={() => validateCompany(draft) ? setError(validateCompany(draft)) : go("mode")} + onBack={back("provider")} + onNext={guard(() => validateCompany(draft), "mode")} /> )} {step === "mode" && ( - go("company")} onNext={() => go("mission")} /> + setStep("mission")} /> )} {step === "mission" && ( go("mode")} + onBack={back("mode")} /> )} - {step === "launch" && null} - {error &&
{error}
} + {error &&
{error}
}
); } +interface StepProps { + draft: Draft; + onChange: (patch: Partial) => void; + onBack: () => void; + onNext: () => void; +} + function WelcomeStep({ onNext }: { onNext: () => void }) { return (

Welcome

- +

+ AgentCompany sets up your operating loop in five steps. Bring an API key, pick a runtime + for the founding CEO, and we'll wire up the first goal, project, and task. +

+
    +
  • Connect one model provider (OpenAI, Anthropic, xAI, or Groq).
  • +
  • Name the company and the human board chair.
  • +
  • Pick execution mode and bind the workspace path.
  • +
  • State the first goal — we kick off the rest.
  • +
+
+ +
); } @@ -183,28 +218,41 @@ function ProviderStep({ return (

Provider

+

+ Pick the runtime for the founding CEO. You can attach more providers in Settings later. +

{providers.map((provider) => ( ))}
- onChange({ providerSecret: event.target.value })} /> + onChange({ providerSecret: event.target.value })} + /> onChange({ providerModel: event.target.value })} /> - +
); } @@ -214,18 +262,18 @@ function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {

Company

- onChange({ companyName: event.target.value })} autoFocus /> + onChange({ companyName: event.target.value })} autoFocus required /> - onChange({ employeeName: event.target.value })} /> + onChange({ employeeName: event.target.value })} required /> -