bopodev-api 0.1.28 → 0.1.30
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 +4 -4
- package/src/app.ts +17 -69
- package/src/lib/ceo-bootstrap-prompt.ts +1 -0
- package/src/lib/run-artifact-paths.ts +8 -0
- package/src/middleware/cors-config.ts +36 -0
- package/src/middleware/request-actor.ts +10 -16
- package/src/middleware/request-id.ts +9 -0
- package/src/middleware/request-logging.ts +24 -0
- package/src/realtime/office-space.ts +1 -0
- package/src/routes/agents.ts +90 -46
- package/src/routes/companies.ts +20 -1
- package/src/routes/goals.ts +7 -13
- package/src/routes/governance.ts +2 -5
- package/src/routes/heartbeats.ts +7 -25
- package/src/routes/issues.ts +65 -120
- package/src/routes/observability.ts +6 -1
- package/src/routes/plugins.ts +5 -17
- package/src/routes/projects.ts +7 -25
- package/src/routes/templates.ts +6 -21
- package/src/scripts/onboard-seed.ts +18 -8
- package/src/server.ts +33 -292
- package/src/services/company-export-service.ts +63 -0
- package/src/services/governance-service.ts +10 -14
- package/src/services/heartbeat-service/active-runs.ts +15 -0
- package/src/services/heartbeat-service/budget-override.ts +46 -0
- package/src/services/heartbeat-service/claims.ts +61 -0
- package/src/services/heartbeat-service/cron.ts +58 -0
- package/src/services/heartbeat-service/heartbeat-realtime.ts +28 -0
- package/src/services/heartbeat-service/heartbeat-run-summary-text.ts +53 -0
- package/src/services/{heartbeat-service.ts → heartbeat-service/heartbeat-run.ts} +201 -634
- package/src/services/heartbeat-service/index.ts +5 -0
- package/src/services/heartbeat-service/stop.ts +90 -0
- package/src/services/heartbeat-service/sweep.ts +145 -0
- package/src/services/heartbeat-service/types.ts +66 -0
- package/src/services/memory-file-service.ts +10 -2
- package/src/services/template-apply-service.ts +6 -0
- package/src/services/template-catalog.ts +37 -3
- package/src/shutdown/graceful-shutdown.ts +77 -0
- package/src/startup/database.ts +41 -0
- package/src/startup/deployment-validation.ts +37 -0
- package/src/startup/env.ts +17 -0
- package/src/startup/runtime-health.ts +128 -0
- package/src/startup/scheduler-config.ts +39 -0
- package/src/types/express.d.ts +13 -0
- package/src/types/request-actor.ts +6 -0
- package/src/validation/issue-routes.ts +80 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { claimIssuesForAgent, releaseClaimedIssues } from "./claims";
|
|
2
|
+
export { findPendingProjectBudgetOverrideBlocksForAgent } from "./budget-override";
|
|
3
|
+
export { stopHeartbeatRun } from "./stop";
|
|
4
|
+
export { runHeartbeatSweep } from "./sweep";
|
|
5
|
+
export { runHeartbeatForAgent } from "./heartbeat-run";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { appendAuditEvent } from "bopodev-db";
|
|
2
|
+
import { and, eq, heartbeatRuns } from "bopodev-db";
|
|
3
|
+
import type { BopoDb } from "bopodev-db";
|
|
4
|
+
import type { RealtimeHub } from "../../realtime/hub";
|
|
5
|
+
import { getActiveHeartbeatRun } from "./active-runs";
|
|
6
|
+
import { publishHeartbeatRunStatus } from "./heartbeat-realtime";
|
|
7
|
+
import type { HeartbeatRunTrigger } from "./types";
|
|
8
|
+
|
|
9
|
+
export async function stopHeartbeatRun(
|
|
10
|
+
db: BopoDb,
|
|
11
|
+
companyId: string,
|
|
12
|
+
runId: string,
|
|
13
|
+
options?: { requestId?: string; actorId?: string; trigger?: HeartbeatRunTrigger; realtimeHub?: RealtimeHub }
|
|
14
|
+
) {
|
|
15
|
+
const runTrigger = options?.trigger ?? "manual";
|
|
16
|
+
const [run] = await db
|
|
17
|
+
.select({
|
|
18
|
+
id: heartbeatRuns.id,
|
|
19
|
+
status: heartbeatRuns.status,
|
|
20
|
+
agentId: heartbeatRuns.agentId
|
|
21
|
+
})
|
|
22
|
+
.from(heartbeatRuns)
|
|
23
|
+
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
|
|
24
|
+
.limit(1);
|
|
25
|
+
if (!run) {
|
|
26
|
+
return { ok: false as const, reason: "not_found" as const };
|
|
27
|
+
}
|
|
28
|
+
if (run.status !== "started") {
|
|
29
|
+
return { ok: false as const, reason: "invalid_status" as const, status: run.status };
|
|
30
|
+
}
|
|
31
|
+
const active = getActiveHeartbeatRun(runId);
|
|
32
|
+
const cancelReason = "cancelled by stop request";
|
|
33
|
+
const cancelRequestedAt = new Date().toISOString();
|
|
34
|
+
if (active) {
|
|
35
|
+
active.cancelReason = cancelReason;
|
|
36
|
+
active.cancelRequestedAt = cancelRequestedAt;
|
|
37
|
+
active.cancelRequestedBy = options?.actorId ?? null;
|
|
38
|
+
active.abortController.abort(cancelReason);
|
|
39
|
+
} else {
|
|
40
|
+
const finishedAt = new Date();
|
|
41
|
+
await db
|
|
42
|
+
.update(heartbeatRuns)
|
|
43
|
+
.set({
|
|
44
|
+
status: "failed",
|
|
45
|
+
finishedAt,
|
|
46
|
+
message: "Heartbeat cancelled by stop request."
|
|
47
|
+
})
|
|
48
|
+
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
|
|
49
|
+
publishHeartbeatRunStatus(options?.realtimeHub, {
|
|
50
|
+
companyId,
|
|
51
|
+
runId,
|
|
52
|
+
status: "failed",
|
|
53
|
+
message: "Heartbeat cancelled by stop request.",
|
|
54
|
+
finishedAt
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
await appendAuditEvent(db, {
|
|
58
|
+
companyId,
|
|
59
|
+
actorType: "system",
|
|
60
|
+
eventType: "heartbeat.cancel_requested",
|
|
61
|
+
entityType: "heartbeat_run",
|
|
62
|
+
entityId: runId,
|
|
63
|
+
correlationId: options?.requestId ?? runId,
|
|
64
|
+
payload: {
|
|
65
|
+
agentId: run.agentId,
|
|
66
|
+
trigger: runTrigger,
|
|
67
|
+
requestId: options?.requestId ?? null,
|
|
68
|
+
actorId: options?.actorId ?? null,
|
|
69
|
+
inMemoryAbortRegistered: Boolean(active)
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (!active) {
|
|
73
|
+
await appendAuditEvent(db, {
|
|
74
|
+
companyId,
|
|
75
|
+
actorType: "system",
|
|
76
|
+
eventType: "heartbeat.cancelled",
|
|
77
|
+
entityType: "heartbeat_run",
|
|
78
|
+
entityId: runId,
|
|
79
|
+
correlationId: options?.requestId ?? runId,
|
|
80
|
+
payload: {
|
|
81
|
+
agentId: run.agentId,
|
|
82
|
+
reason: cancelReason,
|
|
83
|
+
trigger: runTrigger,
|
|
84
|
+
requestId: options?.requestId ?? null,
|
|
85
|
+
actorId: options?.actorId ?? null
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { ok: true as const, runId, agentId: run.agentId, status: run.status };
|
|
90
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { appendAuditEvent } from "bopodev-db";
|
|
2
|
+
import { agents, eq, heartbeatRuns, max } from "bopodev-db";
|
|
3
|
+
import type { BopoDb } from "bopodev-db";
|
|
4
|
+
import type { RealtimeHub } from "../../realtime/hub";
|
|
5
|
+
import { findPendingProjectBudgetOverrideBlocksForAgent } from "./budget-override";
|
|
6
|
+
import { isHeartbeatDue } from "./cron";
|
|
7
|
+
|
|
8
|
+
export async function runHeartbeatSweep(
|
|
9
|
+
db: BopoDb,
|
|
10
|
+
companyId: string,
|
|
11
|
+
options?: { requestId?: string; realtimeHub?: RealtimeHub }
|
|
12
|
+
) {
|
|
13
|
+
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
|
14
|
+
const latestRunByAgent = await listLatestRunByAgent(db, companyId);
|
|
15
|
+
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const enqueuedJobIds: string[] = [];
|
|
18
|
+
const dueAgents: Array<{ id: string }> = [];
|
|
19
|
+
let skippedNotDue = 0;
|
|
20
|
+
let skippedStatus = 0;
|
|
21
|
+
let skippedBudgetBlocked = 0;
|
|
22
|
+
let failedStarts = 0;
|
|
23
|
+
const sweepStartedAt = Date.now();
|
|
24
|
+
for (const agent of companyAgents) {
|
|
25
|
+
if (agent.status !== "idle" && agent.status !== "running") {
|
|
26
|
+
skippedStatus += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!isHeartbeatDue(agent.heartbeatCron, latestRunByAgent.get(agent.id) ?? null, now)) {
|
|
30
|
+
skippedNotDue += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const blockedProjectIds = await findPendingProjectBudgetOverrideBlocksForAgent(db, companyId, agent.id);
|
|
34
|
+
if (blockedProjectIds.length > 0) {
|
|
35
|
+
skippedBudgetBlocked += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
dueAgents.push({ id: agent.id });
|
|
39
|
+
}
|
|
40
|
+
const sweepConcurrency = resolveHeartbeatSweepConcurrency(dueAgents.length);
|
|
41
|
+
const queueModule = await import("../heartbeat-queue-service");
|
|
42
|
+
await runWithConcurrency(dueAgents, sweepConcurrency, async (agent) => {
|
|
43
|
+
try {
|
|
44
|
+
const job = await queueModule.enqueueHeartbeatQueueJob(db, {
|
|
45
|
+
companyId,
|
|
46
|
+
agentId: agent.id,
|
|
47
|
+
jobType: "scheduler",
|
|
48
|
+
priority: 80,
|
|
49
|
+
idempotencyKey: options?.requestId ? `scheduler:${agent.id}:${options.requestId}` : null,
|
|
50
|
+
payload: {}
|
|
51
|
+
});
|
|
52
|
+
enqueuedJobIds.push(job.id);
|
|
53
|
+
queueModule.triggerHeartbeatQueueWorker(db, companyId, {
|
|
54
|
+
requestId: options?.requestId,
|
|
55
|
+
realtimeHub: options?.realtimeHub
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
failedStarts += 1;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
await appendAuditEvent(db, {
|
|
62
|
+
companyId,
|
|
63
|
+
actorType: "system",
|
|
64
|
+
eventType: "heartbeat.sweep.completed",
|
|
65
|
+
entityType: "company",
|
|
66
|
+
entityId: companyId,
|
|
67
|
+
correlationId: options?.requestId ?? null,
|
|
68
|
+
payload: {
|
|
69
|
+
runIds: enqueuedJobIds,
|
|
70
|
+
startedCount: enqueuedJobIds.length,
|
|
71
|
+
dueCount: dueAgents.length,
|
|
72
|
+
failedStarts,
|
|
73
|
+
skippedStatus,
|
|
74
|
+
skippedNotDue,
|
|
75
|
+
skippedBudgetBlocked,
|
|
76
|
+
concurrency: sweepConcurrency,
|
|
77
|
+
elapsedMs: Date.now() - sweepStartedAt,
|
|
78
|
+
requestId: options?.requestId ?? null
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return enqueuedJobIds;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function listLatestRunByAgent(db: BopoDb, companyId: string) {
|
|
85
|
+
const rows = await db
|
|
86
|
+
.select({
|
|
87
|
+
agentId: heartbeatRuns.agentId,
|
|
88
|
+
latestStartedAt: max(heartbeatRuns.startedAt)
|
|
89
|
+
})
|
|
90
|
+
.from(heartbeatRuns)
|
|
91
|
+
.where(eq(heartbeatRuns.companyId, companyId))
|
|
92
|
+
.groupBy(heartbeatRuns.agentId);
|
|
93
|
+
const latestRunByAgent = new Map<string, Date>();
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const startedAt = coerceDate(row.latestStartedAt);
|
|
96
|
+
if (!startedAt) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
latestRunByAgent.set(row.agentId, startedAt);
|
|
100
|
+
}
|
|
101
|
+
return latestRunByAgent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function coerceDate(value: unknown) {
|
|
105
|
+
if (value instanceof Date) {
|
|
106
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
107
|
+
}
|
|
108
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
109
|
+
const parsed = new Date(value);
|
|
110
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveHeartbeatSweepConcurrency(dueAgentsCount: number) {
|
|
116
|
+
const configured = Number(process.env.BOPO_HEARTBEAT_SWEEP_CONCURRENCY ?? "4");
|
|
117
|
+
const fallback = 4;
|
|
118
|
+
const normalized = Number.isFinite(configured) ? Math.floor(configured) : fallback;
|
|
119
|
+
if (normalized < 1) {
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const bounded = Math.min(normalized, 16);
|
|
123
|
+
return Math.min(bounded, Math.max(1, dueAgentsCount));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runWithConcurrency<T>(
|
|
127
|
+
items: T[],
|
|
128
|
+
concurrency: number,
|
|
129
|
+
worker: (item: T, index: number) => Promise<void>
|
|
130
|
+
) {
|
|
131
|
+
if (items.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const workerCount = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
|
135
|
+
let cursor = 0;
|
|
136
|
+
await Promise.all(
|
|
137
|
+
Array.from({ length: workerCount }, async () => {
|
|
138
|
+
while (cursor < items.length) {
|
|
139
|
+
const index = cursor;
|
|
140
|
+
cursor += 1;
|
|
141
|
+
await worker(items[index] as T, index);
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RunCompletionReason } from "bopodev-contracts";
|
|
2
|
+
|
|
3
|
+
export type HeartbeatRunTrigger = "manual" | "scheduler";
|
|
4
|
+
export type HeartbeatRunMode = "default" | "resume" | "redo";
|
|
5
|
+
export type HeartbeatProviderType =
|
|
6
|
+
| "claude_code"
|
|
7
|
+
| "codex"
|
|
8
|
+
| "cursor"
|
|
9
|
+
| "opencode"
|
|
10
|
+
| "gemini_cli"
|
|
11
|
+
| "openai_api"
|
|
12
|
+
| "anthropic_api"
|
|
13
|
+
| "openclaw_gateway"
|
|
14
|
+
| "http"
|
|
15
|
+
| "shell";
|
|
16
|
+
|
|
17
|
+
export type ActiveHeartbeatRun = {
|
|
18
|
+
companyId: string;
|
|
19
|
+
agentId: string;
|
|
20
|
+
abortController: AbortController;
|
|
21
|
+
cancelReason?: string | null;
|
|
22
|
+
cancelRequestedAt?: string | null;
|
|
23
|
+
cancelRequestedBy?: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type HeartbeatWakeContext = {
|
|
27
|
+
reason?: string | null;
|
|
28
|
+
commentId?: string | null;
|
|
29
|
+
commentBody?: string | null;
|
|
30
|
+
issueIds?: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RunDigestSignal = {
|
|
34
|
+
sequence: number;
|
|
35
|
+
kind: "system" | "assistant" | "thinking" | "tool_call" | "tool_result" | "result" | "stderr";
|
|
36
|
+
label: string | null;
|
|
37
|
+
text: string | null;
|
|
38
|
+
payload: string | null;
|
|
39
|
+
signalLevel: "high" | "medium" | "low" | "noise";
|
|
40
|
+
groupKey: string | null;
|
|
41
|
+
source: "stdout" | "stderr" | "trace_fallback";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RunDigest = {
|
|
45
|
+
status: "completed" | "failed" | "skipped";
|
|
46
|
+
headline: string;
|
|
47
|
+
summary: string;
|
|
48
|
+
successes: string[];
|
|
49
|
+
failures: string[];
|
|
50
|
+
blockers: string[];
|
|
51
|
+
nextAction: string;
|
|
52
|
+
evidence: {
|
|
53
|
+
transcriptSignalCount: number;
|
|
54
|
+
outcomeActionCount: number;
|
|
55
|
+
outcomeBlockerCount: number;
|
|
56
|
+
failureType: string | null;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RunTerminalPresentation = {
|
|
61
|
+
internalStatus: "completed" | "failed" | "skipped";
|
|
62
|
+
publicStatus: "completed" | "failed";
|
|
63
|
+
completionReason: RunCompletionReason;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type HeartbeatIdlePolicy = "full" | "skip_adapter" | "micro_prompt";
|
|
@@ -106,6 +106,7 @@ export async function persistHeartbeatMemory(input: {
|
|
|
106
106
|
goalContext?: {
|
|
107
107
|
companyGoals?: string[];
|
|
108
108
|
projectGoals?: string[];
|
|
109
|
+
agentGoals?: string[];
|
|
109
110
|
};
|
|
110
111
|
}): Promise<PersistedHeartbeatMemory> {
|
|
111
112
|
const memoryRoot = resolveAgentMemoryRootPath(input.companyId, input.agentId);
|
|
@@ -237,6 +238,7 @@ function deriveCandidateFacts(
|
|
|
237
238
|
goalContext?: {
|
|
238
239
|
companyGoals?: string[];
|
|
239
240
|
projectGoals?: string[];
|
|
241
|
+
agentGoals?: string[];
|
|
240
242
|
};
|
|
241
243
|
}
|
|
242
244
|
): MemoryCandidateFact[] {
|
|
@@ -575,6 +577,7 @@ function deriveImpactTags(
|
|
|
575
577
|
goalContext?: {
|
|
576
578
|
companyGoals?: string[];
|
|
577
579
|
projectGoals?: string[];
|
|
580
|
+
agentGoals?: string[];
|
|
578
581
|
}
|
|
579
582
|
) {
|
|
580
583
|
const tags = new Set<string>();
|
|
@@ -595,7 +598,9 @@ function deriveImpactTags(
|
|
|
595
598
|
if (scoreTextMatch(fact, missionTokens) > 0) {
|
|
596
599
|
tags.add("mission");
|
|
597
600
|
}
|
|
598
|
-
const goalTokens = tokenize(
|
|
601
|
+
const goalTokens = tokenize(
|
|
602
|
+
[...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? []), ...(goalContext?.agentGoals ?? [])].join(" ")
|
|
603
|
+
);
|
|
599
604
|
if (scoreTextMatch(fact, goalTokens) > 0) {
|
|
600
605
|
tags.add("goal");
|
|
601
606
|
}
|
|
@@ -608,10 +613,13 @@ function computeMissionAlignmentScore(
|
|
|
608
613
|
goalContext?: {
|
|
609
614
|
companyGoals?: string[];
|
|
610
615
|
projectGoals?: string[];
|
|
616
|
+
agentGoals?: string[];
|
|
611
617
|
}
|
|
612
618
|
) {
|
|
613
619
|
const missionTokens = tokenize(mission ?? "");
|
|
614
|
-
const goalTokens = tokenize(
|
|
620
|
+
const goalTokens = tokenize(
|
|
621
|
+
[...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? []), ...(goalContext?.agentGoals ?? [])].join(" ")
|
|
622
|
+
);
|
|
615
623
|
const missionScore = scoreTextMatch(summary, missionTokens);
|
|
616
624
|
const goalScore = scoreTextMatch(summary, goalTokens);
|
|
617
625
|
return Math.min(1, missionScore * 0.55 + goalScore * 0.45);
|
|
@@ -62,6 +62,7 @@ export async function applyTemplateManifest(
|
|
|
62
62
|
role: resolveAgentRoleText(agent.role, agent.roleKey, agent.title),
|
|
63
63
|
roleKey: normalizeRoleKey(agent.roleKey),
|
|
64
64
|
title: normalizeTitle(agent.title),
|
|
65
|
+
capabilities: normalizeCapabilities(agent.capabilities),
|
|
65
66
|
name: agent.name,
|
|
66
67
|
providerType: agent.providerType,
|
|
67
68
|
heartbeatCron: agent.heartbeatCron,
|
|
@@ -152,6 +153,11 @@ function normalizeTitle(input: string | null | undefined) {
|
|
|
152
153
|
return normalized ? normalized : null;
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
function normalizeCapabilities(input: string | null | undefined) {
|
|
157
|
+
const normalized = input?.trim();
|
|
158
|
+
return normalized ? normalized : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
155
161
|
function resolveAgentRoleText(
|
|
156
162
|
legacyRole: string | undefined,
|
|
157
163
|
roleKeyInput: string | undefined,
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
createTemplate,
|
|
10
10
|
createTemplateVersion,
|
|
11
11
|
getTemplateBySlug,
|
|
12
|
-
getTemplateVersionByVersion
|
|
12
|
+
getTemplateVersionByVersion,
|
|
13
|
+
updateTemplate
|
|
13
14
|
} from "bopodev-db";
|
|
14
15
|
|
|
15
16
|
type BuiltinTemplateDefinition = {
|
|
@@ -28,7 +29,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
28
29
|
slug: "founder-startup-basic",
|
|
29
30
|
name: "Founder Startup Basic",
|
|
30
31
|
description: "Baseline operating company for solo founders launching and shipping with AI agents.",
|
|
31
|
-
version: "1.0.
|
|
32
|
+
version: "1.0.1",
|
|
32
33
|
status: "published",
|
|
33
34
|
visibility: "company",
|
|
34
35
|
variables: [
|
|
@@ -78,6 +79,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
78
79
|
{
|
|
79
80
|
key: "founder-ceo",
|
|
80
81
|
role: "CEO",
|
|
82
|
+
roleKey: "ceo",
|
|
83
|
+
title: "Founder CEO",
|
|
84
|
+
capabilities:
|
|
85
|
+
"Sets company priorities, runs leadership cadence, hires and coordinates agents toward mission outcomes.",
|
|
81
86
|
name: "Founder CEO",
|
|
82
87
|
providerType: "codex",
|
|
83
88
|
heartbeatCron: "*/15 * * * *",
|
|
@@ -119,6 +124,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
119
124
|
{
|
|
120
125
|
key: "founding-engineer",
|
|
121
126
|
role: "Founding Engineer",
|
|
127
|
+
roleKey: "engineer",
|
|
128
|
+
title: "Founding Engineer",
|
|
129
|
+
capabilities:
|
|
130
|
+
"Ships product improvements with small reviewable changes, tests, and clear handoffs to stakeholders.",
|
|
122
131
|
name: "Founding Engineer",
|
|
123
132
|
managerAgentKey: "founder-ceo",
|
|
124
133
|
providerType: "codex",
|
|
@@ -152,6 +161,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
152
161
|
{
|
|
153
162
|
key: "growth-operator",
|
|
154
163
|
role: "Growth Operator",
|
|
164
|
+
roleKey: "general",
|
|
165
|
+
title: "Growth Operator",
|
|
166
|
+
capabilities:
|
|
167
|
+
"Runs growth experiments, measures funnel impact, and feeds learnings back to leadership with clear next steps.",
|
|
155
168
|
name: "Growth Operator",
|
|
156
169
|
managerAgentKey: "founder-ceo",
|
|
157
170
|
providerType: "codex",
|
|
@@ -225,7 +238,7 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
225
238
|
slug: "marketing-content-engine",
|
|
226
239
|
name: "Marketing Content Engine",
|
|
227
240
|
description: "Content marketing operating template for publishing, distribution, and analytics loops.",
|
|
228
|
-
version: "1.0.
|
|
241
|
+
version: "1.0.1",
|
|
229
242
|
status: "published",
|
|
230
243
|
visibility: "company",
|
|
231
244
|
variables: [
|
|
@@ -276,6 +289,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
276
289
|
{
|
|
277
290
|
key: "head-of-marketing",
|
|
278
291
|
role: "Head of Marketing",
|
|
292
|
+
roleKey: "cmo",
|
|
293
|
+
title: "Head of Marketing",
|
|
294
|
+
capabilities:
|
|
295
|
+
"Owns marketing narrative, cross-functional alignment, and weekly performance decisions for pipeline growth.",
|
|
279
296
|
name: "Head of Marketing",
|
|
280
297
|
providerType: "codex",
|
|
281
298
|
heartbeatCron: "*/20 * * * *",
|
|
@@ -308,6 +325,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
308
325
|
{
|
|
309
326
|
key: "content-strategist",
|
|
310
327
|
role: "Content Strategist",
|
|
328
|
+
roleKey: "general",
|
|
329
|
+
title: "Content Strategist",
|
|
330
|
+
capabilities:
|
|
331
|
+
"Builds editorial calendars, briefs, and topic architecture tied to audience segments and revenue goals.",
|
|
311
332
|
name: "Content Strategist",
|
|
312
333
|
managerAgentKey: "head-of-marketing",
|
|
313
334
|
providerType: "codex",
|
|
@@ -337,6 +358,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
337
358
|
{
|
|
338
359
|
key: "content-writer",
|
|
339
360
|
role: "Content Writer",
|
|
361
|
+
roleKey: "general",
|
|
362
|
+
title: "Content Writer",
|
|
363
|
+
capabilities:
|
|
364
|
+
"Produces channel-ready drafts, headline and CTA options, and repurposing notes aligned to campaign intent.",
|
|
340
365
|
name: "Content Writer",
|
|
341
366
|
managerAgentKey: "head-of-marketing",
|
|
342
367
|
providerType: "codex",
|
|
@@ -367,6 +392,10 @@ const builtinTemplateDefinitions: BuiltinTemplateDefinition[] = [
|
|
|
367
392
|
{
|
|
368
393
|
key: "distribution-manager",
|
|
369
394
|
role: "Distribution Manager",
|
|
395
|
+
roleKey: "general",
|
|
396
|
+
title: "Distribution Manager",
|
|
397
|
+
capabilities:
|
|
398
|
+
"Distributes and repurposes assets across channels with tracking discipline and weekly performance reporting.",
|
|
370
399
|
name: "Distribution Manager",
|
|
371
400
|
managerAgentKey: "head-of-marketing",
|
|
372
401
|
providerType: "codex",
|
|
@@ -482,6 +511,11 @@ export async function ensureCompanyBuiltinTemplateDefaults(db: BopoDb, companyId
|
|
|
482
511
|
version: definition.version,
|
|
483
512
|
manifestJson: JSON.stringify(manifest)
|
|
484
513
|
});
|
|
514
|
+
await updateTemplate(db, {
|
|
515
|
+
companyId,
|
|
516
|
+
id: template.id,
|
|
517
|
+
currentVersion: definition.version
|
|
518
|
+
});
|
|
485
519
|
}
|
|
486
520
|
}
|
|
487
521
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import type { RealtimeHub } from "../realtime/hub";
|
|
3
|
+
import { beginIssueCommentDispatchShutdown, waitForIssueCommentDispatchDrain } from "../services/comment-recipient-dispatch-service";
|
|
4
|
+
import { beginHeartbeatQueueShutdown, waitForHeartbeatQueueDrain } from "../services/heartbeat-queue-service";
|
|
5
|
+
|
|
6
|
+
export async function closeDatabaseClient(client: unknown) {
|
|
7
|
+
if (!client || typeof client !== "object") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const closeFn = (client as { close?: unknown }).close;
|
|
11
|
+
if (typeof closeFn !== "function") {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await (closeFn as () => Promise<void>)();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function attachGracefulShutdownHandlers(options: {
|
|
18
|
+
server: Server;
|
|
19
|
+
realtimeHub: RealtimeHub;
|
|
20
|
+
dbClient: unknown;
|
|
21
|
+
scheduler?: { stop: () => Promise<void> };
|
|
22
|
+
}) {
|
|
23
|
+
const { server, realtimeHub, dbClient, scheduler } = options;
|
|
24
|
+
let shutdownInFlight: Promise<void> | null = null;
|
|
25
|
+
|
|
26
|
+
function shutdown(signal: string) {
|
|
27
|
+
const shutdownTimeoutMs = Number(process.env.BOPO_SHUTDOWN_TIMEOUT_MS ?? 15_000);
|
|
28
|
+
const forcedExit = setTimeout(() => {
|
|
29
|
+
// eslint-disable-next-line no-console
|
|
30
|
+
console.error(`[shutdown] timed out after ${shutdownTimeoutMs}ms; forcing exit.`);
|
|
31
|
+
process.exit(process.exitCode ?? 1);
|
|
32
|
+
}, shutdownTimeoutMs);
|
|
33
|
+
forcedExit.unref();
|
|
34
|
+
shutdownInFlight ??= (async () => {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.log(`[shutdown] ${signal} — draining HTTP/background work before closing the embedded database…`);
|
|
37
|
+
beginHeartbeatQueueShutdown();
|
|
38
|
+
beginIssueCommentDispatchShutdown();
|
|
39
|
+
await Promise.allSettled([
|
|
40
|
+
scheduler?.stop() ?? Promise.resolve(),
|
|
41
|
+
new Promise<void>((resolve, reject) => {
|
|
42
|
+
server.close((err) => {
|
|
43
|
+
if (err) {
|
|
44
|
+
reject(err);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
})
|
|
50
|
+
]);
|
|
51
|
+
await Promise.allSettled([waitForHeartbeatQueueDrain(), waitForIssueCommentDispatchDrain()]);
|
|
52
|
+
try {
|
|
53
|
+
await realtimeHub.close();
|
|
54
|
+
} catch (closeError) {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.error("[shutdown] realtime hub close error", closeError);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await closeDatabaseClient(dbClient);
|
|
60
|
+
} catch (closeDbError) {
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.error("[shutdown] database close error", closeDbError);
|
|
63
|
+
}
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.log("[shutdown] clean exit");
|
|
66
|
+
process.exitCode = 0;
|
|
67
|
+
})().catch((error) => {
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.error("[shutdown] failed", error);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
});
|
|
72
|
+
return shutdownInFlight;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
76
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { bootstrapDatabase, resolveDefaultDbPath } from "bopodev-db";
|
|
2
|
+
|
|
3
|
+
export function isProbablyDatabaseStartupError(error: unknown): boolean {
|
|
4
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
6
|
+
const causeMessage = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
7
|
+
return (
|
|
8
|
+
message.includes("database") ||
|
|
9
|
+
message.includes("postgres") ||
|
|
10
|
+
message.includes("migration") ||
|
|
11
|
+
causeMessage.includes("postgres") ||
|
|
12
|
+
causeMessage.includes("connection")
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type BootstrappedDb = Awaited<ReturnType<typeof bootstrapDatabase>>;
|
|
17
|
+
|
|
18
|
+
export async function bootstrapDatabaseWithStartupLogging(dbPath: string | undefined): Promise<BootstrappedDb> {
|
|
19
|
+
const usingExternalDatabase = Boolean(process.env.DATABASE_URL?.trim());
|
|
20
|
+
const effectiveDbPath = dbPath ?? resolveDefaultDbPath();
|
|
21
|
+
try {
|
|
22
|
+
return await bootstrapDatabase(dbPath);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (isProbablyDatabaseStartupError(error)) {
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.error("[startup] Database bootstrap failed before the API could start.");
|
|
27
|
+
if (usingExternalDatabase) {
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.error("[startup] Check DATABASE_URL connectivity, permissions, and migration state.");
|
|
30
|
+
} else {
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.error(`[startup] Embedded Postgres data path: ${effectiveDbPath}`);
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.error(
|
|
35
|
+
"[startup] Recovery: stop all API/node processes using this DB, back up the path above, delete the directory if it is corrupted, then restart. Or set BOPO_DB_PATH to a fresh path."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isAuthenticatedMode, type DeploymentMode } from "../security/deployment-mode";
|
|
2
|
+
|
|
3
|
+
export function validateDeploymentConfiguration(
|
|
4
|
+
deploymentMode: DeploymentMode,
|
|
5
|
+
allowedOrigins: string[],
|
|
6
|
+
allowedHostnames: string[],
|
|
7
|
+
publicBaseUrl: URL | null
|
|
8
|
+
) {
|
|
9
|
+
if (deploymentMode === "authenticated_public" && !publicBaseUrl) {
|
|
10
|
+
throw new Error("BOPO_PUBLIC_BASE_URL is required in authenticated_public mode.");
|
|
11
|
+
}
|
|
12
|
+
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_AUTH_TOKEN_SECRET?.trim() === "") {
|
|
13
|
+
throw new Error("BOPO_AUTH_TOKEN_SECRET must not be empty when set.");
|
|
14
|
+
}
|
|
15
|
+
if (isAuthenticatedMode(deploymentMode) && !process.env.BOPO_AUTH_TOKEN_SECRET?.trim()) {
|
|
16
|
+
// eslint-disable-next-line no-console
|
|
17
|
+
console.warn(
|
|
18
|
+
"[startup] BOPO_AUTH_TOKEN_SECRET is not set. Authenticated modes will require BOPO_TRUST_ACTOR_HEADERS=1 behind a trusted proxy."
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
isAuthenticatedMode(deploymentMode) &&
|
|
23
|
+
process.env.BOPO_TRUST_ACTOR_HEADERS !== "1" &&
|
|
24
|
+
!process.env.BOPO_AUTH_TOKEN_SECRET?.trim()
|
|
25
|
+
) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"Authenticated mode requires either BOPO_AUTH_TOKEN_SECRET (token identity) or BOPO_TRUST_ACTOR_HEADERS=1 (trusted proxy headers)."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (isAuthenticatedMode(deploymentMode) && process.env.BOPO_ALLOW_LOCAL_BOARD_FALLBACK === "1") {
|
|
31
|
+
throw new Error("BOPO_ALLOW_LOCAL_BOARD_FALLBACK cannot be enabled in authenticated modes.");
|
|
32
|
+
}
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.log(
|
|
35
|
+
`[startup] Deployment config: mode=${deploymentMode} origins=${allowedOrigins.join(",")} hosts=${allowedHostnames.join(",")}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { config as loadDotenv } from "dotenv";
|
|
4
|
+
|
|
5
|
+
export function loadApiEnv() {
|
|
6
|
+
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const repoRoot = resolve(sourceDir, "../../../../");
|
|
8
|
+
const candidates = [resolve(repoRoot, ".env.local"), resolve(repoRoot, ".env")];
|
|
9
|
+
for (const path of candidates) {
|
|
10
|
+
loadDotenv({ path, override: false, quiet: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeOptionalDbPath(value: string | undefined) {
|
|
15
|
+
const normalized = value?.trim();
|
|
16
|
+
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
17
|
+
}
|