bopodev-api 0.1.30 → 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.
- package/package.json +8 -4
- package/src/app.ts +4 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/middleware/cors-config.ts +1 -1
- package/src/routes/assistant.ts +109 -0
- package/src/routes/companies.ts +112 -1
- package/src/routes/loops.ts +360 -0
- package/src/routes/observability.ts +255 -2
- package/src/services/agent-operating-file-service.ts +116 -0
- package/src/services/company-assistant-brain.ts +50 -0
- package/src/services/company-assistant-cli.ts +388 -0
- package/src/services/company-assistant-context-snapshot.ts +287 -0
- package/src/services/company-assistant-llm.ts +375 -0
- package/src/services/company-assistant-service.ts +1012 -0
- package/src/services/company-file-archive-service.ts +444 -0
- package/src/services/company-file-import-service.ts +279 -0
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -2
- package/src/services/memory-file-service.ts +105 -1
- package/src/services/template-apply-service.ts +33 -0
- package/src/services/template-catalog.ts +19 -6
- package/src/services/work-loop-service/index.ts +2 -0
- package/src/services/work-loop-service/loop-cron.ts +197 -0
- package/src/services/work-loop-service/work-loop-service.ts +665 -0
- package/src/worker/scheduler.ts +26 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { unzipSync } from "fflate";
|
|
4
|
+
import { parse as yamlParse } from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { BopoDb } from "bopodev-db";
|
|
7
|
+
import { createAgent, createCompany, createProject } from "bopodev-db";
|
|
8
|
+
import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
|
|
9
|
+
import {
|
|
10
|
+
resolveAgentMemoryRootPath,
|
|
11
|
+
resolveAgentOperatingPath,
|
|
12
|
+
resolveCompanyProjectsWorkspacePath
|
|
13
|
+
} from "../lib/instance-paths";
|
|
14
|
+
import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
15
|
+
import { ensureBuiltinPluginsRegistered } from "./plugin-runtime";
|
|
16
|
+
import { ensureCompanyBuiltinTemplateDefaults } from "./template-catalog";
|
|
17
|
+
import { addWorkLoopTrigger, createWorkLoop } from "./work-loop-service/work-loop-service";
|
|
18
|
+
|
|
19
|
+
const EXPORT_SCHEMA = "bopo/company-export/v1";
|
|
20
|
+
|
|
21
|
+
const BopoExportYamlSchema = z.object({
|
|
22
|
+
schema: z.string(),
|
|
23
|
+
company: z.object({
|
|
24
|
+
name: z.string().min(1),
|
|
25
|
+
mission: z.string().nullable().optional(),
|
|
26
|
+
slug: z.string().optional()
|
|
27
|
+
}),
|
|
28
|
+
projects: z.record(z.string(), z.object({ name: z.string().min(1), description: z.string().nullable().optional(), status: z.string().optional() })),
|
|
29
|
+
agents: z.record(
|
|
30
|
+
z.string(),
|
|
31
|
+
z.object({
|
|
32
|
+
name: z.string().min(1),
|
|
33
|
+
role: z.string().min(1),
|
|
34
|
+
roleKey: z.string().nullable().optional(),
|
|
35
|
+
title: z.string().nullable().optional(),
|
|
36
|
+
capabilities: z.string().nullable().optional(),
|
|
37
|
+
managerSlug: z.string().nullable().optional(),
|
|
38
|
+
providerType: z.string().min(1),
|
|
39
|
+
heartbeatCron: z.string().min(1),
|
|
40
|
+
canHireAgents: z.boolean().optional()
|
|
41
|
+
})
|
|
42
|
+
),
|
|
43
|
+
routines: z
|
|
44
|
+
.record(
|
|
45
|
+
z.string(),
|
|
46
|
+
z.object({
|
|
47
|
+
title: z.string().min(1),
|
|
48
|
+
description: z.string().nullable().optional(),
|
|
49
|
+
projectSlug: z.string().min(1),
|
|
50
|
+
assigneeAgentSlug: z.string().min(1),
|
|
51
|
+
triggers: z
|
|
52
|
+
.array(
|
|
53
|
+
z.object({
|
|
54
|
+
cronExpression: z.string().min(1),
|
|
55
|
+
timezone: z.string().optional(),
|
|
56
|
+
label: z.string().nullable().optional()
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
.min(1)
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
.optional()
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export class CompanyFileImportError extends Error {
|
|
66
|
+
constructor(message: string) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "CompanyFileImportError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeZipPath(key: string): string | null {
|
|
73
|
+
const t = key.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
74
|
+
if (!t || t.includes("..")) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return t;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decodeZipEntries(buffer: Buffer): Record<string, string> {
|
|
81
|
+
let raw: Record<string, Uint8Array>;
|
|
82
|
+
try {
|
|
83
|
+
raw = unzipSync(new Uint8Array(buffer));
|
|
84
|
+
} catch {
|
|
85
|
+
throw new CompanyFileImportError("Archive is not a valid zip file.");
|
|
86
|
+
}
|
|
87
|
+
const out: Record<string, string> = {};
|
|
88
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
89
|
+
const path = normalizeZipPath(k);
|
|
90
|
+
if (!path || path.endsWith("/")) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
out[path] = new TextDecoder("utf8", { fatal: false }).decode(v);
|
|
95
|
+
} catch {
|
|
96
|
+
/* skip binary */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const PROVIDER_TYPES = new Set([
|
|
103
|
+
"claude_code",
|
|
104
|
+
"codex",
|
|
105
|
+
"cursor",
|
|
106
|
+
"opencode",
|
|
107
|
+
"gemini_cli",
|
|
108
|
+
"openai_api",
|
|
109
|
+
"anthropic_api",
|
|
110
|
+
"openclaw_gateway",
|
|
111
|
+
"http",
|
|
112
|
+
"shell"
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
type AgentProvider = NonNullable<Parameters<typeof createAgent>[1]["providerType"]>;
|
|
116
|
+
|
|
117
|
+
function coerceProviderType(raw: string): AgentProvider {
|
|
118
|
+
return PROVIDER_TYPES.has(raw) ? (raw as AgentProvider) : "shell";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function importCompanyFromZipBuffer(db: BopoDb, buffer: Buffer): Promise<{ companyId: string; name: string }> {
|
|
122
|
+
const entries = decodeZipEntries(buffer);
|
|
123
|
+
const yamlText = entries[".bopo.yaml"] ?? entries["bopo.yaml"];
|
|
124
|
+
if (!yamlText?.trim()) {
|
|
125
|
+
throw new CompanyFileImportError('Zip must contain a ".bopo.yaml" manifest at the archive root.');
|
|
126
|
+
}
|
|
127
|
+
let parsedYaml: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsedYaml = yamlParse(yamlText);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new CompanyFileImportError(".bopo.yaml is not valid YAML.");
|
|
132
|
+
}
|
|
133
|
+
const parsed = BopoExportYamlSchema.safeParse(parsedYaml);
|
|
134
|
+
if (!parsed.success) {
|
|
135
|
+
throw new CompanyFileImportError(`Invalid export manifest: ${parsed.error.message}`);
|
|
136
|
+
}
|
|
137
|
+
const doc = parsed.data;
|
|
138
|
+
if (doc.schema !== EXPORT_SCHEMA) {
|
|
139
|
+
throw new CompanyFileImportError(`Unsupported export schema '${doc.schema}' (expected ${EXPORT_SCHEMA}).`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const created = await createCompany(db, {
|
|
143
|
+
name: doc.company.name,
|
|
144
|
+
mission: doc.company.mission ?? null
|
|
145
|
+
});
|
|
146
|
+
const companyId = created.id;
|
|
147
|
+
const cwd = await resolveDefaultRuntimeCwdForCompany(db, companyId);
|
|
148
|
+
await mkdir(cwd, { recursive: true });
|
|
149
|
+
await ensureBuiltinPluginsRegistered(db, [companyId]);
|
|
150
|
+
await ensureCompanyBuiltinTemplateDefaults(db, companyId);
|
|
151
|
+
|
|
152
|
+
const projectSlugToId = new Map<string, string>();
|
|
153
|
+
const projectStatuses = new Set(["planned", "active", "paused", "blocked", "completed", "archived"]);
|
|
154
|
+
for (const [projectSlug, p] of Object.entries(doc.projects)) {
|
|
155
|
+
const st = p.status?.trim();
|
|
156
|
+
const status = st && projectStatuses.has(st) ? (st as "planned") : "planned";
|
|
157
|
+
const row = await createProject(db, {
|
|
158
|
+
companyId,
|
|
159
|
+
name: p.name,
|
|
160
|
+
description: p.description ?? null,
|
|
161
|
+
status
|
|
162
|
+
});
|
|
163
|
+
if (row) {
|
|
164
|
+
projectSlugToId.set(projectSlug, row.id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const agentSlugToId = new Map<string, string>();
|
|
169
|
+
const agentEntries = Object.entries(doc.agents);
|
|
170
|
+
const pending = new Map(agentEntries);
|
|
171
|
+
let guard = 0;
|
|
172
|
+
while (pending.size > 0 && guard < 500) {
|
|
173
|
+
guard += 1;
|
|
174
|
+
let progressed = false;
|
|
175
|
+
for (const [slug, a] of [...pending.entries()]) {
|
|
176
|
+
const mgrSlug = a.managerSlug?.trim() || null;
|
|
177
|
+
let managerId: string | null = null;
|
|
178
|
+
if (mgrSlug) {
|
|
179
|
+
managerId = agentSlugToId.get(mgrSlug) ?? null;
|
|
180
|
+
if (!managerId) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const defaultRt = normalizeRuntimeConfig({
|
|
185
|
+
defaultRuntimeCwd: cwd,
|
|
186
|
+
runtimeConfig: { runtimeModel: undefined, runtimeEnv: {} }
|
|
187
|
+
});
|
|
188
|
+
const createdAgent = await createAgent(db, {
|
|
189
|
+
companyId,
|
|
190
|
+
managerAgentId: managerId,
|
|
191
|
+
role: a.role,
|
|
192
|
+
roleKey: a.roleKey?.trim() || null,
|
|
193
|
+
title: a.title?.trim() || null,
|
|
194
|
+
capabilities: a.capabilities?.trim() || null,
|
|
195
|
+
name: a.name,
|
|
196
|
+
providerType: coerceProviderType(a.providerType),
|
|
197
|
+
heartbeatCron: a.heartbeatCron,
|
|
198
|
+
monthlyBudgetUsd: "100.0000",
|
|
199
|
+
canHireAgents: a.canHireAgents ?? false,
|
|
200
|
+
...runtimeConfigToDb(defaultRt),
|
|
201
|
+
initialState: runtimeConfigToStateBlobPatch(defaultRt)
|
|
202
|
+
});
|
|
203
|
+
agentSlugToId.set(slug, createdAgent.id);
|
|
204
|
+
pending.delete(slug);
|
|
205
|
+
progressed = true;
|
|
206
|
+
}
|
|
207
|
+
if (!progressed) {
|
|
208
|
+
throw new CompanyFileImportError("Could not resolve agent manager chain (circular or missing manager slug).");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const companyRoot = resolveCompanyProjectsWorkspacePath(companyId);
|
|
213
|
+
for (const [path, text] of Object.entries(entries)) {
|
|
214
|
+
if (path === ".bopo.yaml" || path === "bopo.yaml" || path === "COMPANY.md" || path === "README.md") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (path.startsWith("projects/") && path.endsWith("/PROJECT.md")) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (path.startsWith("tasks/") && path.endsWith("/TASK.md")) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (path.startsWith("agents/")) {
|
|
224
|
+
const parts = path.split("/").filter(Boolean);
|
|
225
|
+
if (parts.length < 3) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const agentSlug = parts[1]!;
|
|
229
|
+
const agentId = agentSlugToId.get(agentSlug);
|
|
230
|
+
if (!agentId) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const rest = parts.slice(2).join("/");
|
|
234
|
+
const isMemory = rest.startsWith("memory/");
|
|
235
|
+
const relativePath = isMemory ? rest.slice("memory/".length) : rest;
|
|
236
|
+
const base = isMemory ? resolveAgentMemoryRootPath(companyId, agentId) : resolveAgentOperatingPath(companyId, agentId);
|
|
237
|
+
const dest = join(base, relativePath);
|
|
238
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
239
|
+
await writeFile(dest, text, "utf8");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (path.startsWith("skills/")) {
|
|
243
|
+
const dest = join(companyRoot, path);
|
|
244
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
245
|
+
await writeFile(dest, text, "utf8");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const routines = doc.routines ?? {};
|
|
250
|
+
for (const [, r] of Object.entries(routines)) {
|
|
251
|
+
const projectId = projectSlugToId.get(r.projectSlug);
|
|
252
|
+
const assigneeId = agentSlugToId.get(r.assigneeAgentSlug);
|
|
253
|
+
if (!projectId || !assigneeId) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const loop = await createWorkLoop(db, {
|
|
257
|
+
companyId,
|
|
258
|
+
projectId,
|
|
259
|
+
title: r.title,
|
|
260
|
+
description: r.description?.trim() || null,
|
|
261
|
+
assigneeAgentId: assigneeId
|
|
262
|
+
});
|
|
263
|
+
if (!loop) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
for (const t of r.triggers) {
|
|
267
|
+
await addWorkLoopTrigger(db, {
|
|
268
|
+
companyId,
|
|
269
|
+
workLoopId: loop.id,
|
|
270
|
+
cronExpression: t.cronExpression,
|
|
271
|
+
timezone: t.timezone?.trim() || "UTC",
|
|
272
|
+
label: t.label ?? null,
|
|
273
|
+
enabled: true
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { companyId, name: doc.company.name };
|
|
279
|
+
}
|
|
@@ -669,7 +669,9 @@ export async function runHeartbeatForAgent(
|
|
|
669
669
|
},
|
|
670
670
|
failClosed: false
|
|
671
671
|
});
|
|
672
|
-
const isCommentOrderWake =
|
|
672
|
+
const isCommentOrderWake =
|
|
673
|
+
options?.wakeContext?.reason === "issue_comment_recipient" ||
|
|
674
|
+
options?.wakeContext?.reason === "loop_execution";
|
|
673
675
|
const heartbeatIdlePolicy = resolveHeartbeatIdlePolicy();
|
|
674
676
|
const workItems = isCommentOrderWake ? [] : await claimIssuesForAgent(db, companyId, agentId, runId);
|
|
675
677
|
const wakeWorkItems = await loadWakeContextWorkItems(db, companyId, options?.wakeContext?.issueIds);
|
|
@@ -1941,7 +1943,10 @@ function resolveExecutionWorkItems(
|
|
|
1941
1943
|
wakeContextItems: IssueWorkItemRow[],
|
|
1942
1944
|
wakeContext?: HeartbeatWakeContext
|
|
1943
1945
|
) {
|
|
1944
|
-
if (
|
|
1946
|
+
if (
|
|
1947
|
+
(wakeContext?.reason === "issue_comment_recipient" || wakeContext?.reason === "loop_execution") &&
|
|
1948
|
+
wakeContextItems.length > 0
|
|
1949
|
+
) {
|
|
1945
1950
|
return wakeContextItems;
|
|
1946
1951
|
}
|
|
1947
1952
|
return mergeContextWorkItems(assigned, wakeContextItems);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { AgentMemoryContext } from "bopodev-agent-sdk";
|
|
4
4
|
import {
|
|
5
5
|
isInsidePath,
|
|
@@ -184,6 +184,76 @@ export async function appendDurableFact(input: {
|
|
|
184
184
|
return targetFile;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
export async function listCompanyMemoryFiles(input: { companyId: string; maxFiles?: number }) {
|
|
188
|
+
const root = resolveCompanyMemoryRootPath(input.companyId);
|
|
189
|
+
await mkdir(root, { recursive: true });
|
|
190
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
|
|
191
|
+
const files = await walkFiles(root, maxFiles);
|
|
192
|
+
return files.map((filePath) => ({
|
|
193
|
+
path: filePath,
|
|
194
|
+
relativePath: relative(root, filePath),
|
|
195
|
+
memoryRoot: root
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function readCompanyMemoryFile(input: { companyId: string; relativePath: string }) {
|
|
200
|
+
const root = resolveCompanyMemoryRootPath(input.companyId);
|
|
201
|
+
await mkdir(root, { recursive: true });
|
|
202
|
+
const candidate = resolve(root, input.relativePath);
|
|
203
|
+
if (!isInsidePath(root, candidate)) {
|
|
204
|
+
throw new Error("Requested memory path is outside of memory root.");
|
|
205
|
+
}
|
|
206
|
+
const info = await stat(candidate);
|
|
207
|
+
if (!info.isFile()) {
|
|
208
|
+
throw new Error("Requested memory path is not a file.");
|
|
209
|
+
}
|
|
210
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
211
|
+
throw new Error("Requested memory file exceeds size limit.");
|
|
212
|
+
}
|
|
213
|
+
const content = await readFile(candidate, "utf8");
|
|
214
|
+
return {
|
|
215
|
+
path: candidate,
|
|
216
|
+
relativePath: relative(root, candidate),
|
|
217
|
+
content,
|
|
218
|
+
sizeBytes: info.size
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function listProjectMemoryFiles(input: { companyId: string; projectId: string; maxFiles?: number }) {
|
|
223
|
+
const root = resolveProjectMemoryRootPath(input.companyId, input.projectId);
|
|
224
|
+
await mkdir(root, { recursive: true });
|
|
225
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? 100));
|
|
226
|
+
const files = await walkFiles(root, maxFiles);
|
|
227
|
+
return files.map((filePath) => ({
|
|
228
|
+
path: filePath,
|
|
229
|
+
relativePath: relative(root, filePath),
|
|
230
|
+
memoryRoot: root
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function readProjectMemoryFile(input: { companyId: string; projectId: string; relativePath: string }) {
|
|
235
|
+
const root = resolveProjectMemoryRootPath(input.companyId, input.projectId);
|
|
236
|
+
await mkdir(root, { recursive: true });
|
|
237
|
+
const candidate = resolve(root, input.relativePath);
|
|
238
|
+
if (!isInsidePath(root, candidate)) {
|
|
239
|
+
throw new Error("Requested memory path is outside of memory root.");
|
|
240
|
+
}
|
|
241
|
+
const info = await stat(candidate);
|
|
242
|
+
if (!info.isFile()) {
|
|
243
|
+
throw new Error("Requested memory path is not a file.");
|
|
244
|
+
}
|
|
245
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
246
|
+
throw new Error("Requested memory file exceeds size limit.");
|
|
247
|
+
}
|
|
248
|
+
const content = await readFile(candidate, "utf8");
|
|
249
|
+
return {
|
|
250
|
+
path: candidate,
|
|
251
|
+
relativePath: relative(root, candidate),
|
|
252
|
+
content,
|
|
253
|
+
sizeBytes: info.size
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
187
257
|
export async function listAgentMemoryFiles(input: {
|
|
188
258
|
companyId: string;
|
|
189
259
|
agentId: string;
|
|
@@ -227,6 +297,40 @@ export async function readAgentMemoryFile(input: {
|
|
|
227
297
|
};
|
|
228
298
|
}
|
|
229
299
|
|
|
300
|
+
export async function writeAgentMemoryFile(input: {
|
|
301
|
+
companyId: string;
|
|
302
|
+
agentId: string;
|
|
303
|
+
relativePath: string;
|
|
304
|
+
content: string;
|
|
305
|
+
}) {
|
|
306
|
+
const root = resolveAgentMemoryRootPath(input.companyId, input.agentId);
|
|
307
|
+
await mkdir(root, { recursive: true });
|
|
308
|
+
const normalizedRel = input.relativePath.trim();
|
|
309
|
+
if (!normalizedRel || normalizedRel.includes("..")) {
|
|
310
|
+
throw new Error("Invalid relative path.");
|
|
311
|
+
}
|
|
312
|
+
const candidate = resolve(root, normalizedRel);
|
|
313
|
+
if (!isInsidePath(root, candidate)) {
|
|
314
|
+
throw new Error("Requested memory path is outside of memory root.");
|
|
315
|
+
}
|
|
316
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
317
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
318
|
+
throw new Error("Content exceeds size limit.");
|
|
319
|
+
}
|
|
320
|
+
const parent = dirname(candidate);
|
|
321
|
+
if (!isInsidePath(root, parent)) {
|
|
322
|
+
throw new Error("Invalid parent directory.");
|
|
323
|
+
}
|
|
324
|
+
await mkdir(parent, { recursive: true });
|
|
325
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
326
|
+
const info = await stat(candidate);
|
|
327
|
+
return {
|
|
328
|
+
path: candidate,
|
|
329
|
+
relativePath: relative(root, candidate),
|
|
330
|
+
sizeBytes: info.size
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
230
334
|
function collapseWhitespace(value: string) {
|
|
231
335
|
return value.replace(/\s+/g, " ").trim();
|
|
232
336
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
updatePluginConfig
|
|
10
10
|
} from "bopodev-db";
|
|
11
11
|
import { interpolateTemplateManifest, buildTemplatePreview } from "./template-preview-service";
|
|
12
|
+
import { addWorkLoopTrigger, createWorkLoop } from "./work-loop-service";
|
|
12
13
|
|
|
13
14
|
export class TemplateApplyError extends Error {
|
|
14
15
|
constructor(message: string) {
|
|
@@ -123,6 +124,38 @@ export async function applyTemplateManifest(
|
|
|
123
124
|
});
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
const firstProjectId =
|
|
128
|
+
renderedManifest.projects.length > 0
|
|
129
|
+
? projectIdByKey.get(renderedManifest.projects[0]!.key) ?? null
|
|
130
|
+
: Array.from(projectIdByKey.values())[0] ?? null;
|
|
131
|
+
for (const job of renderedManifest.recurrence) {
|
|
132
|
+
if (job.targetType !== "agent") {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const assigneeAgentId = agentIdByKey.get(job.targetKey) ?? null;
|
|
136
|
+
if (!assigneeAgentId || !firstProjectId) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const title =
|
|
140
|
+
job.instruction?.trim() && job.instruction.trim().length > 0
|
|
141
|
+
? job.instruction.trim()
|
|
142
|
+
: `Recurring work: ${job.targetKey}`;
|
|
143
|
+
const loop = await createWorkLoop(db, {
|
|
144
|
+
companyId: input.companyId,
|
|
145
|
+
projectId: firstProjectId,
|
|
146
|
+
title,
|
|
147
|
+
description: job.instruction?.trim() || null,
|
|
148
|
+
assigneeAgentId
|
|
149
|
+
});
|
|
150
|
+
if (loop) {
|
|
151
|
+
await addWorkLoopTrigger(db, {
|
|
152
|
+
companyId: input.companyId,
|
|
153
|
+
workLoopId: loop.id,
|
|
154
|
+
cronExpression: job.cron
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
126
159
|
const install = await createTemplateInstall(db, {
|
|
127
160
|
companyId: input.companyId,
|
|
128
161
|
templateId: input.templateId,
|
|
@@ -28,8 +28,9 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
28
28
|
{
|
|
29
29
|
slug: "founder-startup-basic",
|
|
30
30
|
name: "Founder Startup Basic",
|
|
31
|
-
description:
|
|
32
|
-
|
|
31
|
+
description:
|
|
32
|
+
"Baseline operating company for solo founders launching and shipping with AI agents. Aligns with Bopo company file export (.bopo.yaml, projects/, agents/, tasks/) so operators can download a zip, edit markdown in git, and import a new company from the archive.",
|
|
33
|
+
version: "1.0.2",
|
|
33
34
|
status: "published",
|
|
34
35
|
visibility: "company",
|
|
35
36
|
variables: [
|
|
@@ -117,7 +118,11 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
117
118
|
"",
|
|
118
119
|
"Quality bar:",
|
|
119
120
|
"- Be concise, specific, and execution-ready.",
|
|
120
|
-
"- Do not produce generic plans without owners, dates, and measurable outcomes."
|
|
121
|
+
"- Do not produce generic plans without owners, dates, and measurable outcomes.",
|
|
122
|
+
"",
|
|
123
|
+
"Portable company files:",
|
|
124
|
+
"- Under workspace Templates → Export, the app can produce a zip with .bopo.yaml and per-agent markdown under agents/<slug>/.",
|
|
125
|
+
"- That tree can be edited in git and re-imported to create another company; keep those files consistent with how you actually run."
|
|
121
126
|
].join("\n")
|
|
122
127
|
}
|
|
123
128
|
},
|
|
@@ -237,8 +242,9 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
237
242
|
{
|
|
238
243
|
slug: "marketing-content-engine",
|
|
239
244
|
name: "Marketing Content Engine",
|
|
240
|
-
description:
|
|
241
|
-
|
|
245
|
+
description:
|
|
246
|
+
"Content marketing operating template for publishing, distribution, and analytics loops. Uses the same Bopo company zip layout as file export/import so marketing orgs can be versioned in git and cloned via archive import.",
|
|
247
|
+
version: "1.0.2",
|
|
242
248
|
status: "published",
|
|
243
249
|
visibility: "company",
|
|
244
250
|
variables: [
|
|
@@ -318,7 +324,11 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
318
324
|
"",
|
|
319
325
|
"Escalation:",
|
|
320
326
|
"- Escalate major brand/positioning shifts, compliance-sensitive claims, or budget overrun risks.",
|
|
321
|
-
"- If dependencies block publication, create unblock issues within 24h."
|
|
327
|
+
"- If dependencies block publication, create unblock issues within 24h.",
|
|
328
|
+
"",
|
|
329
|
+
"Portable company files:",
|
|
330
|
+
"- Under workspace Templates → Export, leaders can download a zip with .bopo.yaml and agent markdown under agents/<slug>/.",
|
|
331
|
+
"- That folder tree can be edited in git and re-imported to stand up a new company; keep exported docs aligned with campaigns you actually ship."
|
|
322
332
|
].join("\n")
|
|
323
333
|
}
|
|
324
334
|
},
|
|
@@ -514,6 +524,9 @@ export async function ensureCompanyBuiltinTemplateDefaults(db: BopoDb, companyId
|
|
|
514
524
|
await updateTemplate(db, {
|
|
515
525
|
companyId,
|
|
516
526
|
id: template.id,
|
|
527
|
+
name: definition.name,
|
|
528
|
+
description: definition.description,
|
|
529
|
+
variablesJson: JSON.stringify(variables),
|
|
517
530
|
currentVersion: definition.version
|
|
518
531
|
});
|
|
519
532
|
}
|