Rebuild AgentCompany Electron app
This commit is contained in:
1 parent
bc5daa6eb8
commit
fcf6d90731
56 files changed
+8667
No files matched your search
@@ -0,0 +1,66 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/commands";
|
||||
import { APPROVAL_STATE } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class ApprovalService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateApprovalInput): string {
|
||||
const id = createId("app");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
|
||||
state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.kind.trim(),
|
||||
input.title.trim(),
|
||||
input.details.trim(),
|
||||
input.risk,
|
||||
input.requestedByType,
|
||||
input.requestedById,
|
||||
APPROVAL_STATE.PENDING,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: id,
|
||||
actorType: input.requestedByType,
|
||||
actorId: input.requestedById,
|
||||
action: "requested",
|
||||
payload: { kind: input.kind, risk: input.risk },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
decide(input: DecideApprovalInput): void {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update approvals
|
||||
set state = ?, decided_by_employee_id = ?, decided_at = ?
|
||||
where id = ? and org_id = ? and state = ?
|
||||
`).run(input.state, input.decidedByEmployeeId, now, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
||||
if (result.changes === 0) throw new Error("Pending approval was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "approval",
|
||||
entityId: input.approvalId,
|
||||
actorType: "employee",
|
||||
actorId: input.decidedByEmployeeId,
|
||||
action: input.state,
|
||||
payload: { decidedAt: now },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
export class AuditService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
record(input: {
|
||||
orgId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: unknown;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert into audit_events(id, org_id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("aud"),
|
||||
input.orgId,
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
input.actorType,
|
||||
input.actorId,
|
||||
input.action,
|
||||
JSON.stringify(input.payload),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SendMessageInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class ChatService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
sendMessage(input: SendMessageInput): string {
|
||||
const body = input.bodyMd.trim();
|
||||
if (!body) throw new Error("Message body is required.");
|
||||
const id = createId("msg");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into chat_messages(
|
||||
id, org_id, space_id, thread_id, sender_type, sender_id, body_md, created_at, edited_at, deleted_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.spaceId,
|
||||
input.threadId,
|
||||
input.senderType,
|
||||
input.senderId,
|
||||
body,
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "chat_message",
|
||||
entityId: id,
|
||||
actorType: input.senderType,
|
||||
actorId: input.senderId,
|
||||
action: "sent",
|
||||
payload: { spaceId: input.spaceId, threadId: input.threadId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { dialog } from "electron";
|
||||
import type { ImportFileInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
function fileKind(path: string): string {
|
||||
const ext = extname(path).toLowerCase().replace(".", "");
|
||||
if (!ext) return "file";
|
||||
if (["md", "markdown"].includes(ext)) return "md";
|
||||
if (["pdf"].includes(ext)) return "pdf";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img";
|
||||
if (["csv", "xlsx", "xls"].includes(ext)) return "sheet";
|
||||
if (["doc", "docx", "txt"].includes(ext)) return "doc";
|
||||
return ext;
|
||||
}
|
||||
|
||||
export class FileService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
private readonly artifactRoot: string,
|
||||
) {
|
||||
this.audit = new AuditService(db);
|
||||
mkdirSync(this.artifactRoot, { recursive: true });
|
||||
}
|
||||
|
||||
async importFile(input: ImportFileInput): Promise<string | null> {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
securityScopedBookmarks: true,
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return this.importPath(input.orgId, result.filePaths[0]);
|
||||
}
|
||||
|
||||
importPath(orgId: string, sourcePath: string): string {
|
||||
const id = createId("fil");
|
||||
const now = new Date().toISOString();
|
||||
const stats = statSync(sourcePath);
|
||||
if (!stats.isFile()) throw new Error("Selected path is not a file.");
|
||||
const hash = createHash("sha256").update(readFileSync(sourcePath)).digest("hex");
|
||||
const orgDir = join(this.artifactRoot, orgId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
orgId,
|
||||
basename(sourcePath),
|
||||
fileKind(sourcePath),
|
||||
stats.size,
|
||||
hash,
|
||||
target,
|
||||
"system",
|
||||
null,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "file_object",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "imported",
|
||||
payload: { sha256: hash, sizeBytes: stats.size },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput } from "../../shared/commands";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class OrganizationService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createOrganization(input: CreateOrganizationInput): string {
|
||||
const orgId = createId("org");
|
||||
const employeeId = createId("emp");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
this.db.prepare("insert into organizations(id, name, mission, goal, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(orgId, input.organizationName.trim(), input.mission.trim(), input.goal.trim(), now);
|
||||
this.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(employeeId, orgId, input.employeeName.trim(), "Board Chair", now);
|
||||
|
||||
for (const space of [
|
||||
["company", "general", "Company coordination", "workspace"],
|
||||
["department", "product", "Product decisions", "workspace"],
|
||||
["department", "engineering", "Engineering delivery", "workspace"],
|
||||
["company", "board", "Board decisions", "restricted"],
|
||||
]) {
|
||||
const spaceId = createId("spc");
|
||||
this.db.prepare(`
|
||||
insert into chat_spaces(id, org_id, space_type, name, topic, visibility, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(spaceId, orgId, space[0], space[1], space[2], space[3], now);
|
||||
this.db.prepare(`
|
||||
insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(spaceId, "employee", employeeId, space[1] === "board" ? "approver" : "member", "unmuted");
|
||||
}
|
||||
|
||||
for (const provider of PROVIDER_CATALOG) {
|
||||
this.db.prepare(`
|
||||
insert into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("prv"),
|
||||
orgId,
|
||||
provider.key,
|
||||
provider.label,
|
||||
provider.authType,
|
||||
provider.defaultModel,
|
||||
null,
|
||||
0,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "organization",
|
||||
entityId: orgId,
|
||||
actorType: "employee",
|
||||
actorId: employeeId,
|
||||
action: "created",
|
||||
payload: { name: input.organizationName.trim() },
|
||||
});
|
||||
return orgId;
|
||||
}
|
||||
|
||||
createAgent(input: CreateAgentInput): string {
|
||||
const id = createId("agt");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into agents(
|
||||
id, org_id, name, role, department, model_provider_id, model_id,
|
||||
budget_cents, manager_agent_id, lifecycle, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.name.trim(),
|
||||
input.role.trim(),
|
||||
input.department.trim(),
|
||||
input.modelProviderId,
|
||||
input.modelId,
|
||||
input.budgetCents,
|
||||
input.managerAgentId,
|
||||
"active",
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "agent",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { role: input.role, department: input.department },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
createTask(input: CreateTaskInput): string {
|
||||
const id = createId("tsk");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into tasks(id, org_id, title, description, status, assignee_agent_id, risk, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.title.trim(),
|
||||
input.description.trim(),
|
||||
input.status,
|
||||
input.assigneeAgentId,
|
||||
input.risk,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
if (input.risk === RISK_LEVEL.HIGH) {
|
||||
this.db.prepare(`
|
||||
insert into approvals(
|
||||
id, org_id, kind, title, details, risk, requested_by_type,
|
||||
requested_by_id, state, decided_by_employee_id, created_at, decided_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("app"),
|
||||
input.orgId,
|
||||
"task_risk",
|
||||
input.title.trim(),
|
||||
input.description.trim(),
|
||||
RISK_LEVEL.HIGH,
|
||||
"system",
|
||||
null,
|
||||
APPROVAL_STATE.PENDING,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { status: TASK_STATUS.BACKLOG },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
||||
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(status, new Date().toISOString(), taskId, orgId);
|
||||
if (result.changes === 0) throw new Error("Task was not found.");
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task",
|
||||
entityId: taskId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "status_changed",
|
||||
payload: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { SaveProviderCredentialInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { SecretVault } from "./secret-vault";
|
||||
|
||||
function displayHint(secret: string): string {
|
||||
const clean = secret.trim();
|
||||
if (clean.length <= 10) return `${clean.slice(0, 2)}...`;
|
||||
return `${clean.slice(0, 6)}...${clean.slice(-4)}`;
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly vault = new SecretVault();
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
saveCredential(input: SaveProviderCredentialInput): void {
|
||||
const provider = this.db.prepare("select * from providers where id = ? and org_id = ?")
|
||||
.get(input.providerId, input.orgId) as { provider_key: string; auth_type: string } | undefined;
|
||||
if (!provider) throw new Error("Provider was not found.");
|
||||
const secret = input.secret.trim();
|
||||
if (!secret) throw new Error("Credential is required.");
|
||||
const credentialId = createId("cred");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
credentialId,
|
||||
input.orgId,
|
||||
provider.provider_key,
|
||||
provider.auth_type,
|
||||
this.vault.encryptSecret(secret),
|
||||
displayHint(secret),
|
||||
now,
|
||||
);
|
||||
this.db.prepare("update providers set credential_id = ?, model = ?, enabled = 1 where id = ? and org_id = ?")
|
||||
.run(credentialId, input.model, input.providerId, input.orgId);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "provider",
|
||||
entityId: input.providerId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "credential_saved",
|
||||
payload: { providerKey: provider.provider_key, authType: provider.auth_type },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { safeStorage } from "electron";
|
||||
|
||||
export class SecretVault {
|
||||
encryptSecret(secret: string): Buffer {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error("OS credential encryption is unavailable.");
|
||||
}
|
||||
return safeStorage.encryptString(secret);
|
||||
}
|
||||
|
||||
decryptSecret(ciphertext: Buffer): string {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error("OS credential encryption is unavailable.");
|
||||
}
|
||||
return safeStorage.decryptString(ciphertext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { BindSkillInput, ImportSkillDirectoryInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
interface SkillManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
entrypoint: string;
|
||||
capabilities: string[];
|
||||
requiredTools: string[];
|
||||
compatibleModels: string[];
|
||||
riskLevel: string;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function parseSkillName(markdown: string, directoryPath: string): string {
|
||||
const frontmatterName = markdown.match(/^---[\s\S]*?\nname:\s*["']?([^"'\n]+)["']?/m)?.[1];
|
||||
if (frontmatterName) return frontmatterName.trim();
|
||||
const heading = markdown.match(/^#\s+(.+)$/m)?.[1];
|
||||
return heading?.trim() || basename(directoryPath);
|
||||
}
|
||||
|
||||
function hashFiles(directoryPath: string): string {
|
||||
const hash = createHash("sha256");
|
||||
const files = readdirSync(directoryPath).filter((name) => !name.startsWith(".")).sort();
|
||||
for (const file of files) {
|
||||
const path = join(directoryPath, file);
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
hash.update(file);
|
||||
hash.update(readFileSync(path));
|
||||
}
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export class SkillService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
importDirectory(input: ImportSkillDirectoryInput): string {
|
||||
const skillPath = join(input.directoryPath, "SKILL.md");
|
||||
if (!existsSync(skillPath)) throw new Error("SKILL.md was not found in the selected directory.");
|
||||
const markdown = readFileSync(skillPath, "utf8");
|
||||
const name = parseSkillName(markdown, input.directoryPath);
|
||||
const slug = slugify(name);
|
||||
if (!slug) throw new Error("Skill name could not be derived.");
|
||||
const now = new Date().toISOString();
|
||||
const skillId = createId("skl");
|
||||
const revisionId = createId("skr");
|
||||
const checksum = hashFiles(input.directoryPath);
|
||||
const manifest: SkillManifest = {
|
||||
name,
|
||||
version: input.versionSemver,
|
||||
entrypoint: "SKILL.md",
|
||||
capabilities: [],
|
||||
requiredTools: [],
|
||||
compatibleModels: ["openai/*", "anthropic/*", "xai/*"],
|
||||
riskLevel: "medium",
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, slug) do update set name = excluded.name, status = 'active'
|
||||
`).run(skillId, input.orgId, slug, name, "organization", "active", now);
|
||||
|
||||
const existing = this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?")
|
||||
.get(input.orgId, slug) as { id: string };
|
||||
|
||||
this.db.prepare(`
|
||||
insert into skill_revisions(
|
||||
id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
revisionId,
|
||||
existing.id,
|
||||
input.versionSemver,
|
||||
JSON.stringify(manifest),
|
||||
"local_directory",
|
||||
input.directoryPath,
|
||||
now,
|
||||
checksum,
|
||||
);
|
||||
this.db.prepare(`
|
||||
insert into skill_artifacts(id, revision_id, artifact_type, object_key, sha256, size_bytes)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("ska"), revisionId, "markdown", skillPath, checksum, Buffer.byteLength(markdown));
|
||||
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_revision",
|
||||
entityId: revisionId,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "published",
|
||||
payload: { slug, version: input.versionSemver },
|
||||
});
|
||||
return revisionId;
|
||||
}
|
||||
|
||||
bind(input: BindSkillInput): string {
|
||||
const id = createId("skb");
|
||||
this.db.prepare(`
|
||||
insert into skill_bindings(id, org_id, agent_id, skill_id, pinned_revision_id, binding_mode, priority, enabled)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(agent_id, skill_id) do update set pinned_revision_id = excluded.pinned_revision_id, enabled = 1
|
||||
`).run(id, input.orgId, input.agentId, input.skillId, input.revisionId, "pinned", 100, 1);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "skill_binding",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "bound",
|
||||
payload: { agentId: input.agentId, skillId: input.skillId, revisionId: input.revisionId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
import type { AppSnapshot } from "../../shared/domain";
|
||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function all<T>(statement: StatementSync): T[] {
|
||||
return statement.all() as T[];
|
||||
}
|
||||
|
||||
function toBool(value: unknown): boolean {
|
||||
return value === 1 || value === true;
|
||||
}
|
||||
|
||||
function camel(row: Row): Row {
|
||||
const next: Row = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
next[key.replace(/_([a-z])/g, (_m, ch: string) => ch.toUpperCase())] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function list<T>(db: DatabaseSync, table: string, orderBy: string): T[] {
|
||||
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy}`)).map((row) => camel(row) as T);
|
||||
}
|
||||
|
||||
export class SnapshotService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
load(): AppSnapshot {
|
||||
const organizations = list<AppSnapshot["organizations"][number]>(this.db, "organizations", "created_at asc");
|
||||
const activeOrgId = organizations[0]?.id ?? null;
|
||||
const providers = list<AppSnapshot["providers"][number]>(this.db, "providers", "label asc")
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled) }));
|
||||
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||
.map((binding) => ({ ...binding, enabled: toBool(binding.enabled) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
organizations,
|
||||
employees: list(this.db, "employees", "created_at asc"),
|
||||
agents: list(this.db, "agents", "created_at asc"),
|
||||
tasks: list(this.db, "tasks", "created_at asc"),
|
||||
approvals: list(this.db, "approvals", "created_at desc"),
|
||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||
chatMessages: list(this.db, "chat_messages", "created_at asc"),
|
||||
files: list(this.db, "file_objects", "created_at desc"),
|
||||
providers,
|
||||
providerCatalog: PROVIDER_CATALOG,
|
||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
||||
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
|
||||
skillBindings,
|
||||
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import type { CreateSocialAccountInput } from "../../shared/commands";
|
||||
import { SOCIAL_PLATFORM, type SocialPlatform } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const PLATFORM_LOGIN_URL: Record<SocialPlatform, string> = {
|
||||
[SOCIAL_PLATFORM.X]: "https://x.com/i/flow/login",
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: "https://www.linkedin.com/login",
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: "https://accounts.google.com/",
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: "https://www.instagram.com/accounts/login/",
|
||||
};
|
||||
|
||||
const ALLOWED_HOSTS: Record<SocialPlatform, string[]> = {
|
||||
[SOCIAL_PLATFORM.X]: ["x.com", "twitter.com"],
|
||||
[SOCIAL_PLATFORM.LINKEDIN]: ["linkedin.com", "www.linkedin.com"],
|
||||
[SOCIAL_PLATFORM.YOUTUBE]: ["accounts.google.com", "youtube.com", "www.youtube.com"],
|
||||
[SOCIAL_PLATFORM.INSTAGRAM]: ["instagram.com", "www.instagram.com"],
|
||||
};
|
||||
|
||||
export class SocialService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
createAccount(input: CreateSocialAccountInput): string {
|
||||
const id = createId("soc");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into social_accounts(id, org_id, platform, handle, display_name, authorized_employee_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.platform,
|
||||
input.handle.trim(),
|
||||
input.displayName.trim(),
|
||||
input.authorizedEmployeeId,
|
||||
now,
|
||||
);
|
||||
if (input.authorizedEmployeeId) {
|
||||
this.db.prepare(`
|
||||
insert into social_account_grants(id, org_id, social_account_id, employee_id, role, scope_json, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("sgr"),
|
||||
input.orgId,
|
||||
id,
|
||||
input.authorizedEmployeeId,
|
||||
"operator",
|
||||
JSON.stringify({ actions: ["login", "draft", "approve"] }),
|
||||
now,
|
||||
);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "social_account",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { platform: input.platform, handle: input.handle.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
launchLoginWindow(accountId: string): void {
|
||||
const account = this.db.prepare("select * from social_accounts where id = ?")
|
||||
.get(accountId) as { id: string; org_id: string; platform: SocialPlatform; handle: string } | undefined;
|
||||
if (!account) throw new Error("Social account was not found.");
|
||||
const partition = `persist:agentcompany-social-${account.id}`;
|
||||
const socialSession = session.fromPartition(partition);
|
||||
socialSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
socialSession.setPermissionCheckHandler(() => false);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 860,
|
||||
title: `${account.handle}`,
|
||||
webPreferences: {
|
||||
session: socialSession,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
this.attachNavigationPolicy(win, account.platform);
|
||||
win.loadURL(PLATFORM_LOGIN_URL[account.platform]);
|
||||
this.audit.record({
|
||||
orgId: account.org_id,
|
||||
entityType: "social_account",
|
||||
entityId: account.id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "login_window_opened",
|
||||
payload: { platform: account.platform },
|
||||
});
|
||||
}
|
||||
|
||||
private attachNavigationPolicy(win: BrowserWindow, platform: SocialPlatform): void {
|
||||
const allowed = ALLOWED_HOSTS[platform];
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
if (!allowed.some((entry) => host === entry || host.endsWith(`.${entry}`))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user