bopodev-api 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import type { Readable } from "node:stream";
4
+ import archiver from "archiver";
5
+ import { stringify as yamlStringify } from "yaml";
6
+ import type { BopoDb } from "bopodev-db";
7
+ import { getCompany, listAgents, listProjects } from "bopodev-db";
8
+ import {
9
+ resolveAgentMemoryRootPath,
10
+ resolveAgentOperatingPath,
11
+ resolveCompanyProjectsWorkspacePath
12
+ } from "../lib/instance-paths";
13
+ import { listWorkLoopTriggers, listWorkLoops } from "./work-loop-service/work-loop-service";
14
+
15
+ const EXPORT_SCHEMA = "bopo/company-export/v1";
16
+ const MAX_TEXT_FILE_BYTES = 512_000;
17
+ const MAX_WALK_FILES = 400;
18
+ const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
19
+
20
+ export class CompanyFileArchiveError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "CompanyFileArchiveError";
24
+ }
25
+ }
26
+
27
+ export type CompanyExportFileEntry = {
28
+ path: string;
29
+ bytes: number;
30
+ source: "generated" | "workspace";
31
+ };
32
+
33
+ function slugify(base: string, used: Set<string>): string {
34
+ const raw = base
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, "-")
38
+ .replace(/^-+|-+$/g, "");
39
+ let s = raw.length > 0 ? raw : "item";
40
+ let n = 2;
41
+ while (used.has(s)) {
42
+ s = `${raw}-${n}`;
43
+ n += 1;
44
+ }
45
+ used.add(s);
46
+ return s;
47
+ }
48
+
49
+ function companySlug(name: string, companyId: string) {
50
+ const fromName = slugify(name, new Set());
51
+ return fromName.length >= 2 ? fromName : `company-${companyId.slice(0, 8)}`;
52
+ }
53
+
54
+ async function walkTextFilesUnder(rootAbs: string, budget: { n: number }): Promise<Record<string, string>> {
55
+ const out: Record<string, string> = {};
56
+ async function walk(dir: string) {
57
+ if (budget.n >= MAX_WALK_FILES) {
58
+ return;
59
+ }
60
+ let entries: import("node:fs").Dirent[];
61
+ try {
62
+ entries = await readdir(dir, { withFileTypes: true });
63
+ } catch {
64
+ return;
65
+ }
66
+ for (const ent of entries) {
67
+ if (budget.n >= MAX_WALK_FILES) {
68
+ return;
69
+ }
70
+ const name = ent.name;
71
+ if (name.startsWith(".")) {
72
+ continue;
73
+ }
74
+ const full = join(dir, name);
75
+ if (ent.isDirectory()) {
76
+ await walk(full);
77
+ continue;
78
+ }
79
+ const lower = name.toLowerCase();
80
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
81
+ if (!TEXT_EXT.has(ext)) {
82
+ continue;
83
+ }
84
+ try {
85
+ const st = await stat(full);
86
+ if (!st.isFile() || st.size > MAX_TEXT_FILE_BYTES) {
87
+ continue;
88
+ }
89
+ const body = await readFile(full, "utf8");
90
+ const rel = relative(rootAbs, full).replace(/\\/g, "/");
91
+ out[rel] = body;
92
+ budget.n += 1;
93
+ } catch {
94
+ /* skip */
95
+ }
96
+ }
97
+ }
98
+ await walk(rootAbs);
99
+ return out;
100
+ }
101
+
102
+ async function walkSkillsDir(companyId: string, budget: { n: number }): Promise<Record<string, string>> {
103
+ const root = join(resolveCompanyProjectsWorkspacePath(companyId), "skills");
104
+ const files = await walkTextFilesUnder(root, budget);
105
+ const out: Record<string, string> = {};
106
+ for (const [rel, content] of Object.entries(files)) {
107
+ out[`skills/${rel}`] = content;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function buildReadmeMarkdown(input: {
113
+ companyName: string;
114
+ slug: string;
115
+ agentRows: { slug: string; name: string; role: string; managerSlug: string | null }[];
116
+ projectRows: { slug: string; name: string; description: string | null }[];
117
+ skillFileCount: number;
118
+ taskCount: number;
119
+ exportedAt: string;
120
+ }): string {
121
+ const lines = [
122
+ `# ${input.companyName}`,
123
+ "",
124
+ "## What's inside",
125
+ "",
126
+ "| Content | Count |",
127
+ "|---------|-------|",
128
+ `| Agents | ${input.agentRows.length} |`,
129
+ `| Projects | ${input.projectRows.length} |`,
130
+ `| Skills (files under skills/) | ${input.skillFileCount} |`,
131
+ `| Scheduled tasks | ${input.taskCount} |`,
132
+ "",
133
+ "### Agents",
134
+ "",
135
+ "| Agent | Role | Reports to |",
136
+ "|-------|------|------------|"
137
+ ];
138
+ for (const a of input.agentRows) {
139
+ lines.push(`| ${a.name} | ${a.role} | ${a.managerSlug ?? "—"} |`);
140
+ }
141
+ lines.push("", "### Projects", "");
142
+ for (const p of input.projectRows) {
143
+ const d = p.description?.trim() || "";
144
+ lines.push(`- **${p.name}**${d ? ` — ${d}` : ""}`);
145
+ }
146
+ lines.push(
147
+ "",
148
+ "## Import",
149
+ "",
150
+ "Upload the `.zip` from the Bopo workspace (Company export), or use the API `POST /companies/import/files` with `multipart/form-data` field `archive`.",
151
+ "",
152
+ "---",
153
+ `Exported from Bopo on ${input.exportedAt.slice(0, 10)} (package slug: \`${input.slug}\`).`,
154
+ ""
155
+ );
156
+ return lines.join("\n");
157
+ }
158
+
159
+ export async function buildCompanyExportFileMap(
160
+ db: BopoDb,
161
+ companyId: string,
162
+ options: { includeAgentMemory: boolean }
163
+ ): Promise<{ files: Record<string, string>; manifestPaths: string[] }> {
164
+ const company = await getCompany(db, companyId);
165
+ if (!company) {
166
+ throw new CompanyFileArchiveError("Company not found.");
167
+ }
168
+
169
+ const [projects, agents] = await Promise.all([listProjects(db, companyId), listAgents(db, companyId)]);
170
+ const loops = await listWorkLoops(db, companyId);
171
+
172
+ const usedSlugs = new Set<string>();
173
+ const companySlugValue = companySlug(company.name, company.id);
174
+
175
+ const projectSlugById = new Map<string, string>();
176
+ const projectEntries: { id: string; slug: string; name: string; description: string | null; status: string }[] = [];
177
+ for (const p of projects) {
178
+ const slug = slugify(p.name, usedSlugs);
179
+ projectSlugById.set(p.id, slug);
180
+ projectEntries.push({
181
+ id: p.id,
182
+ slug,
183
+ name: p.name,
184
+ description: p.description ?? null,
185
+ status: p.status
186
+ });
187
+ }
188
+
189
+ const agentSlugById = new Map<string, string>();
190
+ const agentManifest: Record<
191
+ string,
192
+ {
193
+ bopoAgentId: string;
194
+ name: string;
195
+ role: string;
196
+ roleKey: string | null;
197
+ title: string | null;
198
+ capabilities: string | null;
199
+ managerSlug: string | null;
200
+ providerType: string;
201
+ heartbeatCron: string;
202
+ canHireAgents: boolean;
203
+ }
204
+ > = {};
205
+
206
+ const orderedAgents = [...agents].sort((a, b) => a.name.localeCompare(b.name));
207
+ for (const a of orderedAgents) {
208
+ agentSlugById.set(a.id, slugify(a.name, usedSlugs));
209
+ }
210
+ for (const a of orderedAgents) {
211
+ const slug = agentSlugById.get(a.id)!;
212
+ const mgrSlug = a.managerAgentId ? agentSlugById.get(a.managerAgentId) ?? null : null;
213
+ agentManifest[slug] = {
214
+ bopoAgentId: a.id,
215
+ name: a.name,
216
+ role: a.role,
217
+ roleKey: a.roleKey ?? null,
218
+ title: a.title ?? null,
219
+ capabilities: a.capabilities ?? null,
220
+ managerSlug: mgrSlug,
221
+ providerType: a.providerType,
222
+ heartbeatCron: a.heartbeatCron,
223
+ canHireAgents: Boolean(a.canHireAgents)
224
+ };
225
+ }
226
+
227
+ const routineManifest: Record<
228
+ string,
229
+ {
230
+ bopoLoopId: string;
231
+ title: string;
232
+ description: string | null;
233
+ projectSlug: string;
234
+ assigneeAgentSlug: string;
235
+ triggers: { cronExpression: string; timezone: string; label: string | null }[];
236
+ }
237
+ > = {};
238
+
239
+ const usedTaskSlugs = new Set<string>(usedSlugs);
240
+ for (const loop of loops) {
241
+ const triggers = await listWorkLoopTriggers(db, companyId, loop.id);
242
+ const scheduleTriggers = triggers.filter((t) => t.kind === "schedule" && t.enabled !== false);
243
+ if (scheduleTriggers.length === 0) {
244
+ continue;
245
+ }
246
+ const projectSlug = projectSlugById.get(loop.projectId);
247
+ const assigneeSlug = agentSlugById.get(loop.assigneeAgentId);
248
+ if (!projectSlug || !assigneeSlug) {
249
+ continue;
250
+ }
251
+ const taskSlug = slugify(loop.title, usedTaskSlugs);
252
+ routineManifest[taskSlug] = {
253
+ bopoLoopId: loop.id,
254
+ title: loop.title,
255
+ description: loop.description ?? null,
256
+ projectSlug,
257
+ assigneeAgentSlug: assigneeSlug,
258
+ triggers: scheduleTriggers.map((t) => ({
259
+ cronExpression: t.cronExpression,
260
+ timezone: t.timezone ?? "UTC",
261
+ label: t.label ?? null
262
+ }))
263
+ };
264
+ }
265
+
266
+ const yamlDoc = {
267
+ schema: EXPORT_SCHEMA,
268
+ exportedAt: new Date().toISOString(),
269
+ company: {
270
+ bopoCompanyId: company.id,
271
+ name: company.name,
272
+ mission: company.mission ?? null,
273
+ slug: companySlugValue
274
+ },
275
+ projects: Object.fromEntries(projectEntries.map((p) => [p.slug, { bopoProjectId: p.id, name: p.name, description: p.description, status: p.status }])),
276
+ agents: agentManifest,
277
+ routines: routineManifest
278
+ };
279
+
280
+ const files: Record<string, string> = {};
281
+ files[".bopo.yaml"] = yamlStringify(yamlDoc);
282
+
283
+ const mission = company.mission?.trim() ?? "";
284
+ files["COMPANY.md"] = ["---", `name: "${company.name.replace(/"/g, '\\"')}"`, `schema: bopo/company-md/v1`, `slug: "${companySlugValue}"`, "---", "", mission, ""].join("\n");
285
+
286
+ const agentRowsForReadme = orderedAgents.map((a) => ({
287
+ slug: agentSlugById.get(a.id)!,
288
+ name: a.name,
289
+ role: a.role,
290
+ managerSlug: a.managerAgentId ? agentSlugById.get(a.managerAgentId) ?? null : null
291
+ }));
292
+
293
+ const skillBudget = { n: 0 };
294
+ const skillFiles = await walkSkillsDir(companyId, skillBudget);
295
+ const skillFileCount = Object.keys(skillFiles).length;
296
+ for (const [p, c] of Object.entries(skillFiles)) {
297
+ files[p] = c;
298
+ }
299
+
300
+ const taskCount = Object.keys(routineManifest).length;
301
+
302
+ files["README.md"] = buildReadmeMarkdown({
303
+ companyName: company.name,
304
+ slug: companySlugValue,
305
+ agentRows: agentRowsForReadme,
306
+ projectRows: projectEntries.map((p) => ({ slug: p.slug, name: p.name, description: p.description })),
307
+ skillFileCount,
308
+ taskCount,
309
+ exportedAt: yamlDoc.exportedAt
310
+ });
311
+
312
+ for (const p of projectEntries) {
313
+ const desc = p.description?.trim() ?? "";
314
+ const body = ["---", `name: "${p.name.replace(/"/g, '\\"')}"`, desc ? `description: "${desc.replace(/"/g, '\\"')}"` : `description: ""`, `status: "${p.status}"`, "---", "", desc || p.name, ""].join("\n");
315
+ files[`projects/${p.slug}/PROJECT.md`] = body;
316
+ }
317
+
318
+ const walkBudget = { n: 0 };
319
+ for (const a of orderedAgents) {
320
+ const slug = agentSlugById.get(a.id)!;
321
+ const opRoot = resolveAgentOperatingPath(companyId, a.id);
322
+ const opFiles = await walkTextFilesUnder(opRoot, walkBudget);
323
+ for (const [rel, content] of Object.entries(opFiles)) {
324
+ files[`agents/${slug}/${rel}`] = content;
325
+ }
326
+ if (options.includeAgentMemory) {
327
+ const memRoot = resolveAgentMemoryRootPath(companyId, a.id);
328
+ const memFiles = await walkTextFilesUnder(memRoot, walkBudget);
329
+ for (const [rel, content] of Object.entries(memFiles)) {
330
+ files[`agents/${slug}/memory/${rel}`] = content;
331
+ }
332
+ }
333
+ }
334
+
335
+ for (const [taskSlug, r] of Object.entries(routineManifest)) {
336
+ const primary = r.triggers[0]!;
337
+ const front = [
338
+ "---",
339
+ `name: "${r.title.replace(/"/g, '\\"')}"`,
340
+ `assignee: "${r.assigneeAgentSlug}"`,
341
+ `project: "${r.projectSlug}"`,
342
+ `recurring: true`,
343
+ `cronExpression: "${primary.cronExpression}"`,
344
+ `timezone: "${primary.timezone}"`,
345
+ primary.label ? `label: "${String(primary.label).replace(/"/g, '\\"')}"` : "",
346
+ "---",
347
+ "",
348
+ r.description?.trim() || r.title,
349
+ ""
350
+ ]
351
+ .filter(Boolean)
352
+ .join("\n");
353
+ files[`tasks/${taskSlug}/TASK.md`] = front;
354
+ }
355
+
356
+ const manifestPaths = Object.keys(files).sort();
357
+ return { files, manifestPaths };
358
+ }
359
+
360
+ export async function listCompanyExportManifest(
361
+ db: BopoDb,
362
+ companyId: string,
363
+ options: { includeAgentMemory: boolean }
364
+ ): Promise<CompanyExportFileEntry[]> {
365
+ const { files } = await buildCompanyExportFileMap(db, companyId, options);
366
+ return Object.entries(files)
367
+ .map(([path, content]): CompanyExportFileEntry => {
368
+ const source: "generated" | "workspace" =
369
+ path.startsWith("agents/") || path.startsWith("skills/") ? "workspace" : "generated";
370
+ return {
371
+ path,
372
+ bytes: Buffer.byteLength(content, "utf8"),
373
+ source
374
+ };
375
+ })
376
+ .sort((a, b) => a.path.localeCompare(b.path));
377
+ }
378
+
379
+ export function normalizeExportPath(p: string): string | null {
380
+ const t = p.trim().replace(/\\/g, "/").replace(/^\/+/, "");
381
+ if (!t || t.includes("..") || t.startsWith("/")) {
382
+ return null;
383
+ }
384
+ if (!/^[a-zA-Z0-9._ /-]+$/.test(t)) {
385
+ return null;
386
+ }
387
+ return t;
388
+ }
389
+
390
+ export async function pipeCompanyExportZip(
391
+ db: BopoDb,
392
+ companyId: string,
393
+ input: { paths: string[] | null; includeAgentMemory: boolean }
394
+ ): Promise<Readable> {
395
+ const { files, manifestPaths } = await buildCompanyExportFileMap(db, companyId, {
396
+ includeAgentMemory: input.includeAgentMemory
397
+ });
398
+ const allow = new Set(manifestPaths);
399
+ const selected =
400
+ input.paths && input.paths.length > 0
401
+ ? input.paths.flatMap((raw) => {
402
+ const p = normalizeExportPath(raw);
403
+ return p && allow.has(p) ? [p] : [];
404
+ })
405
+ : manifestPaths;
406
+
407
+ if (selected.length === 0) {
408
+ throw new CompanyFileArchiveError("No files selected for export.");
409
+ }
410
+
411
+ const archive = archiver("zip", { zlib: { level: 9 } });
412
+ for (const path of selected) {
413
+ const content = files[path];
414
+ if (content === undefined) {
415
+ continue;
416
+ }
417
+ archive.append(content, { name: path });
418
+ }
419
+ void archive.finalize();
420
+ return archive;
421
+ }
422
+
423
+ /** Stream a single workspace file for preview (path must be under generated export set). */
424
+ export async function readCompanyExportFileText(
425
+ db: BopoDb,
426
+ companyId: string,
427
+ path: string,
428
+ options: { includeAgentMemory: boolean }
429
+ ): Promise<{ content: string; truncated: boolean } | null> {
430
+ const normalized = normalizeExportPath(path);
431
+ if (!normalized) {
432
+ return null;
433
+ }
434
+ const { files } = await buildCompanyExportFileMap(db, companyId, options);
435
+ const content = files[normalized];
436
+ if (content === undefined) {
437
+ return null;
438
+ }
439
+ const max = 120_000;
440
+ if (content.length <= max) {
441
+ return { content, truncated: false };
442
+ }
443
+ return { content: `${content.slice(0, max)}\n\n…(truncated for preview)`, truncated: true };
444
+ }